From 7e466f7bee41d88e01d22e3a4c904e0e578630c9 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Mon, 8 Jun 2020 12:58:25 -0700 Subject: [PATCH 0001/1164] Do not un-escape \ characters when parsing MONITOR output Prior to this, escaped slashes ("\\") were un-escaped. This caused the strings "foo\x92" and "foo\\x92" to be represented the same way in the output. Fixes #1349 --- redis/client.py | 5 ++++- tests/test_monitor.py | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 3471895d6b..5cf778ca24 100755 --- a/redis/client.py +++ b/redis/client.py @@ -3344,7 +3344,10 @@ def next_command(self): m = self.monitor_re.match(command_data) db_id, client_info, command = m.groups() command = ' '.join(self.command_re.findall(command)) - command = command.replace('\\"', '"').replace('\\\\', '\\') + # Redis escapes double quotes because each piece of the command + # string is surrounded by double quotes. We don't have that + # requirement so remove the escaping and leave the quote. + command = command.replace('\\"', '"') if client_info == 'lua': client_address = 'lua' diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 0e39ec064d..ee5dc6e39c 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -34,6 +34,13 @@ def test_command_with_binary_data(self, r): response = wait_for_command(r, m, 'GET foo\\x92') assert response['command'] == 'GET foo\\x92' + def test_command_with_escaped_data(self, r): + with r.monitor() as m: + byte_string = b'foo\\x92' + r.get(byte_string) + response = wait_for_command(r, m, 'GET foo\\\\x92') + assert response['command'] == 'GET foo\\\\x92' + def test_lua_script(self, r): with r.monitor() as m: script = 'return redis.call("GET", "foo")' From 67ae97addcb8344554f703d8ec6e0b7433b84f71 Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Mon, 29 Jun 2020 10:34:46 +0300 Subject: [PATCH 0002/1164] mark and close stale issues and PRs --- .github/workflows/stale-issues.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/stale-issues.yml diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 0000000000..562cd582b1 --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,20 @@ +name: "Close stale issues" +on: + schedule: + - cron: "0 0 * * *" + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v3 + 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.' + stale-pr-message: 'This pull request is marked stale. It will be closed in 30 days if it is not updated.' + days-before-stale: 365 + days-before-close: 30 + stale-issue-label: "Stale" + stale-pr-label: "Stale" + operations-per-run: 10 + remove-stale-when-updated: true From d71379186e75441909657cd2f6395e89583078fd Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 26 Jun 2020 10:07:57 -0700 Subject: [PATCH 0003/1164] Fix acl_setuser docstring Fix the docstring for acl_setuser() so that it refers to the correct parameter name,`passwords`. --- redis/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 5cf778ca24..9de90fbd9a 100755 --- a/redis/client.py +++ b/redis/client.py @@ -993,7 +993,7 @@ def acl_setuser(self, username, enabled=False, nopass=False, ``passwords`` if specified is a list of plain text passwords to add to or remove from the user. Each password must be prefixed with a '+' to add or a '-' to remove. For convenience, the value of - ``add_passwords`` can be a simple prefixed string when adding or + ``passwords`` can be a simple prefixed string when adding or removing a single password. ``hashed_passwords`` if specified is a list of SHA-256 hashed passwords From 67c2fca8bb2208d57f74ad34da52c0ca2d9e29dc Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Mon, 29 Jun 2020 21:58:03 +0300 Subject: [PATCH 0004/1164] upgrade tests to use redis 6.0.5 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d530e4af3f..05dd94c25f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -32,7 +32,7 @@ matrix: - python: pypy3 env: TOXENV=pypy3-hiredis before_install: - - wget https://github.com/antirez/redis/archive/6.0-rc1.tar.gz && mkdir redis_install && tar -xvzf 6.0-rc1.tar.gz -C redis_install && cd redis_install/redis-6.0-rc1 && make && src/redis-server --daemonize yes && cd ../.. + - wget https://github.com/antirez/redis/archive/6.0.5.tar.gz && mkdir redis_install && tar -xvzf 6.0.5.tar.gz -C redis_install && cd redis_install/redis-6.0.5 && make && src/redis-server --daemonize yes && cd ../.. - redis-cli info install: - pip install codecov tox From 4dca0db0092d260c228f4d25e588faebc7927f8f Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Mon, 29 Jun 2020 23:38:33 +0200 Subject: [PATCH 0005/1164] Add `redis.sentinel.Sentinel` module documentation (#1165) Added directive to document Sentinel module. --- docs/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index e441beef74..bc1a4fac41 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,3 +21,6 @@ Contents: .. automodule:: redis :members: + +.. automodule:: redis.sentinel + :members: From a67a47d5c3f4cd22b4a9658aa4b182593906ec39 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Mon, 29 Jun 2020 14:46:46 -0700 Subject: [PATCH 0006/1164] changelog --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 98f3f73679..e20ef3bfe8 100644 --- a/CHANGES +++ b/CHANGES @@ -1,4 +1,4 @@ -* (in development) +* 3.5.3 (June 1, 2020) * Restore try/except clauses to __del__ methods. These will be removed in 4.0 when more explicit resource management if enforced. #1339 * Update the master_address when Sentinels promote a new master. #847 From 10fb0c5814709fd6dca1fc666fbfd8b172fb08bc Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Sun, 5 Jul 2020 09:15:08 +0300 Subject: [PATCH 0007/1164] documentation: fix ssl typos in the changelog --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index e20ef3bfe8..03b2354e9d 100644 --- a/CHANGES +++ b/CHANGES @@ -459,7 +459,7 @@ * Fixed a bug where some encodings (like utf-16) were unusable on Python 3 as command names and literals would get encoded. * Added an SSLConnection class that allows for secure connections through - stunnel or other means. Construct and SSL connection with the sll=True + stunnel or other means. Construct an SSL connection with the ssl=True option on client classes, using the rediss:// scheme from an URL, or by passing the SSLConnection class to a connection pool's connection_class argument. Thanks https://github.com/oranagra. From 7e28b233391e673732fd47d8f78a0364aa5561be Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 8 Jul 2020 18:59:31 +0000 Subject: [PATCH 0008/1164] WIP: All processes in a single container --- Dockerfile | 18 ++++++++++++ build_tools/bootstrap.sh | 3 +- build_tools/build_redis.sh | 7 ++--- build_tools/install_redis.sh | 16 ++++------- build_tools/install_sentinel.sh | 17 ++++-------- build_tools/redis-configs/001-master | 1 + build_tools/redis-configs/002-slave | 1 + build_tools/redis_vars.sh | 41 +++------------------------- build_tools/sentinel-configs/001-1 | 1 + build_tools/sentinel-configs/002-2 | 1 + build_tools/sentinel-configs/003-3 | 1 + build_tools/start.sh | 9 ++++++ docker-compose.yml | 13 +++++++++ 13 files changed, 64 insertions(+), 65 deletions(-) create mode 100644 Dockerfile create mode 100755 build_tools/start.sh create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..898f5bfec4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu:20.04 + +COPY build_tools /build_tools + +RUN /build_tools/bootstrap.sh +RUN /build_tools/build_redis.sh +RUN /build_tools/install_redis.sh +RUN /build_tools/install_sentinel.sh + +COPY build_tools/start.sh /var/lib/redis/start.sh + +EXPOSE 6379 +EXPOSE 6380 +EXPOSE 26379 +EXPOSE 26380 +EXPOSE 26381 + +CMD /var/lib/redis/start.sh diff --git a/build_tools/bootstrap.sh b/build_tools/bootstrap.sh index a5a0d2ce83..7125326de8 100755 --- a/build_tools/bootstrap.sh +++ b/build_tools/bootstrap.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash # need make to build redis -sudo apt-get install make +apt-get update +apt-get install -y make apt-utils wget build-essential diff --git a/build_tools/build_redis.sh b/build_tools/build_redis.sh index 379c6cc936..f530bf0860 100755 --- a/build_tools/build_redis.sh +++ b/build_tools/build_redis.sh @@ -1,11 +1,8 @@ #!/usr/bin/env bash -source /home/vagrant/redis-py/build_tools/redis_vars.sh +source /build_tools/redis_vars.sh -pushd /home/vagrant - -uninstall_all_sentinel_instances -uninstall_all_redis_instances +pushd /tmp # create a clean directory for redis rm -rf $REDIS_DIR diff --git a/build_tools/install_redis.sh b/build_tools/install_redis.sh index fd53a1ca88..0e9d4b2259 100755 --- a/build_tools/install_redis.sh +++ b/build_tools/install_redis.sh @@ -1,28 +1,25 @@ #!/usr/bin/env bash -source /home/vagrant/redis-py/build_tools/redis_vars.sh +source /build_tools/redis_vars.sh -for filename in `ls $VAGRANT_REDIS_CONF_DIR`; do +for filename in `ls $BUILD_REDIS_CONF_DIR`; do # cuts the order prefix off of the filename, e.g. 001-master -> master PROCESS_NAME=redis-`echo $filename | cut -f 2- -d -` echo "======================================" echo "INSTALLING REDIS SERVER: $PROCESS_NAME" echo "======================================" - # make sure the instance is uninstalled (it should be already) - uninstall_instance $PROCESS_NAME - # base config mkdir -p $REDIS_CONF_DIR cp $REDIS_BUILD_DIR/redis.conf $REDIS_CONF_DIR/$PROCESS_NAME.conf # override config values from file - cat $VAGRANT_REDIS_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf + cat $BUILD_REDIS_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf # replace placeholder variables in init.d script - cp $VAGRANT_DIR/redis_init_script /etc/init.d/$PROCESS_NAME + cp $BUILD_DIR/redis_init_script /etc/init.d/$PROCESS_NAME sed -i "s/{{ PROCESS_NAME }}/$PROCESS_NAME/g" /etc/init.d/$PROCESS_NAME # need to read the config file to find out what port this instance will run on - port=`grep port $VAGRANT_REDIS_CONF_DIR/$filename | cut -f 2 -d " "` + port=`grep port $BUILD_REDIS_CONF_DIR/$filename | cut -f 2 -d " "` sed -i "s/{{ PORT }}/$port/g" /etc/init.d/$PROCESS_NAME chmod 755 /etc/init.d/$PROCESS_NAME @@ -31,7 +28,4 @@ for filename in `ls $VAGRANT_REDIS_CONF_DIR`; do # save the $PROCESS_NAME into installed instances file echo $PROCESS_NAME >> $REDIS_INSTALLED_INSTANCES_FILE - - # start redis - /etc/init.d/$PROCESS_NAME start done diff --git a/build_tools/install_sentinel.sh b/build_tools/install_sentinel.sh index 0597208ccf..0aa21effb9 100755 --- a/build_tools/install_sentinel.sh +++ b/build_tools/install_sentinel.sh @@ -1,28 +1,26 @@ #!/usr/bin/env bash -source /home/vagrant/redis-py/build_tools/redis_vars.sh +source /build_tools/redis_vars.sh -for filename in `ls $VAGRANT_SENTINEL_CONF_DIR`; do + +for filename in `ls $BUILD_SENTINEL_CONF_DIR`; do # cuts the order prefix off of the filename, e.g. 001-master -> master PROCESS_NAME=sentinel-`echo $filename | cut -f 2- -d -` echo "=========================================" echo "INSTALLING SENTINEL SERVER: $PROCESS_NAME" echo "=========================================" - # make sure the instance is uninstalled (it should be already) - uninstall_instance $PROCESS_NAME - # base config mkdir -p $REDIS_CONF_DIR cp $REDIS_BUILD_DIR/sentinel.conf $REDIS_CONF_DIR/$PROCESS_NAME.conf # override config values from file - cat $VAGRANT_SENTINEL_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf + cat $BUILD_SENTINEL_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf # replace placeholder variables in init.d script - cp $VAGRANT_DIR/sentinel_init_script /etc/init.d/$PROCESS_NAME + cp $BUILD_DIR/sentinel_init_script /etc/init.d/$PROCESS_NAME sed -i "s/{{ PROCESS_NAME }}/$PROCESS_NAME/g" /etc/init.d/$PROCESS_NAME # need to read the config file to find out what port this instance will run on - port=`grep port $VAGRANT_SENTINEL_CONF_DIR/$filename | cut -f 2 -d " "` + port=`grep port $BUILD_SENTINEL_CONF_DIR/$filename | cut -f 2 -d " "` sed -i "s/{{ PORT }}/$port/g" /etc/init.d/$PROCESS_NAME chmod 755 /etc/init.d/$PROCESS_NAME @@ -31,7 +29,4 @@ for filename in `ls $VAGRANT_SENTINEL_CONF_DIR`; do # save the $PROCESS_NAME into installed instances file echo $PROCESS_NAME >> $SENTINEL_INSTALLED_INSTANCES_FILE - - # start redis - /etc/init.d/$PROCESS_NAME start done diff --git a/build_tools/redis-configs/001-master b/build_tools/redis-configs/001-master index 8591f1a61e..cd1c984a93 100644 --- a/build_tools/redis-configs/001-master +++ b/build_tools/redis-configs/001-master @@ -6,3 +6,4 @@ unixsocket /tmp/redis_master.sock unixsocketperm 777 dbfilename master.rdb dir /var/lib/redis/backups +logfile /var/log/redis-master diff --git a/build_tools/redis-configs/002-slave b/build_tools/redis-configs/002-slave index 13eb77ec4d..b51cb8464c 100644 --- a/build_tools/redis-configs/002-slave +++ b/build_tools/redis-configs/002-slave @@ -6,5 +6,6 @@ unixsocket /tmp/redis-slave.sock unixsocketperm 777 dbfilename slave.rdb dir /var/lib/redis/backups +logfile /var/log/redis-slave slaveof 127.0.0.1 6379 diff --git a/build_tools/redis_vars.sh b/build_tools/redis_vars.sh index c52dd4cf37..b80ff5550e 100755 --- a/build_tools/redis_vars.sh +++ b/build_tools/redis_vars.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -VAGRANT_DIR=/home/vagrant/redis-py/build_tools -VAGRANT_REDIS_CONF_DIR=$VAGRANT_DIR/redis-configs -VAGRANT_SENTINEL_CONF_DIR=$VAGRANT_DIR/sentinel-configs +BUILD_DIR=/build_tools +BUILD_REDIS_CONF_DIR=$BUILD_DIR/redis-configs +BUILD_SENTINEL_CONF_DIR=$BUILD_DIR/sentinel-configs REDIS_VERSION=3.2.0 -REDIS_DOWNLOAD_DIR=/home/vagrant/redis-downloads +REDIS_DOWNLOAD_DIR=/tmp/redis-downloads REDIS_PACKAGE=redis-$REDIS_VERSION.tar.gz REDIS_BUILD_DIR=$REDIS_DOWNLOAD_DIR/redis-$REDIS_VERSION REDIS_DIR=/var/lib/redis @@ -13,36 +13,3 @@ REDIS_CONF_DIR=$REDIS_DIR/conf REDIS_SAVE_DIR=$REDIS_DIR/backups REDIS_INSTALLED_INSTANCES_FILE=$REDIS_DIR/redis-instances SENTINEL_INSTALLED_INSTANCES_FILE=$REDIS_DIR/sentinel-instances - -function uninstall_instance() { - # Expects $1 to be the init.d filename, e.g. redis-nodename or - # sentinel-nodename - - if [ -a /etc/init.d/$1 ]; then - - echo "======================================" - echo "UNINSTALLING REDIS SERVER: $1" - echo "======================================" - - /etc/init.d/$1 stop - update-rc.d -f $1 remove - rm -f /etc/init.d/$1 - fi; - rm -f $REDIS_CONF_DIR/$1.conf -} - -function uninstall_all_redis_instances() { - if [ -a $REDIS_INSTALLED_INSTANCES_FILE ]; then - cat $REDIS_INSTALLED_INSTANCES_FILE | while read line; do - uninstall_instance $line; - done; - fi -} - -function uninstall_all_sentinel_instances() { - if [ -a $SENTINEL_INSTALLED_INSTANCES_FILE ]; then - cat $SENTINEL_INSTALLED_INSTANCES_FILE | while read line; do - uninstall_instance $line; - done; - fi -} diff --git a/build_tools/sentinel-configs/001-1 b/build_tools/sentinel-configs/001-1 index eccc3d1f84..d12979fc0f 100644 --- a/build_tools/sentinel-configs/001-1 +++ b/build_tools/sentinel-configs/001-1 @@ -1,6 +1,7 @@ pidfile /var/run/sentinel-1.pid port 26379 daemonize yes +logfile /var/log/redis-sentinel-1 # short timeout for sentinel tests sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/sentinel-configs/002-2 b/build_tools/sentinel-configs/002-2 index 0cd28019c4..9ab89348a4 100644 --- a/build_tools/sentinel-configs/002-2 +++ b/build_tools/sentinel-configs/002-2 @@ -1,6 +1,7 @@ pidfile /var/run/sentinel-2.pid port 26380 daemonize yes +logfile /var/log/redis-sentinel-2 # short timeout for sentinel tests sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/sentinel-configs/003-3 b/build_tools/sentinel-configs/003-3 index c7f4fcd335..b971b2ff45 100644 --- a/build_tools/sentinel-configs/003-3 +++ b/build_tools/sentinel-configs/003-3 @@ -1,6 +1,7 @@ pidfile /var/run/sentinel-3.pid port 26381 daemonize yes +logfile /var/log/redis-sentinel-3 # short timeout for sentinel tests sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/start.sh b/build_tools/start.sh new file mode 100755 index 0000000000..c394b949aa --- /dev/null +++ b/build_tools/start.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +/etc/init.d/redis-master start +/etc/init.d/redis-slave start +/etc/init.d/sentinel-1 start +/etc/init.d/sentinel-2 start +/etc/init.d/sentinel-3 start + +tail -f /var/log/redis-master /var/log/redis-slave /var/log/redis-sentinel-1 /var/log/redis-sentinel-2 /var/log/redis-sentinel-3 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..9c61dd4cd8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: "3.8" + +services: + redis-py: + build: ./ + ports: + - "6379:6379" + - "6380:6380" + - "26379:26379" + - "26380:26380" + - "26381:26381" + volumes: + - .:/mnt/redis-py From afb4594cbe8dbbfaaa618819fedaf3e823f8f846 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 8 Jul 2020 23:31:14 +0000 Subject: [PATCH 0009/1164] Use one container per process --- Dockerfile | 18 ------------------ docker-compose.yml | 22 ++++++++++++++++++---- docker/master/Dockerfile | 7 +++++++ docker/master/redis.conf | 9 +++++++++ docker/sentinel_1/Dockerfile | 7 +++++++ docker/sentinel_1/redis.conf | 7 +++++++ docker/sentinel_2/Dockerfile | 7 +++++++ docker/sentinel_2/redis.conf | 7 +++++++ docker/sentinel_3/Dockerfile | 7 +++++++ docker/sentinel_3/redis.conf | 7 +++++++ docker/slave/Dockerfile | 7 +++++++ docker/slave/redis.conf | 11 +++++++++++ 12 files changed, 94 insertions(+), 22 deletions(-) delete mode 100644 Dockerfile create mode 100644 docker/master/Dockerfile create mode 100644 docker/master/redis.conf create mode 100644 docker/sentinel_1/Dockerfile create mode 100644 docker/sentinel_1/redis.conf create mode 100644 docker/sentinel_2/Dockerfile create mode 100644 docker/sentinel_2/redis.conf create mode 100644 docker/sentinel_3/Dockerfile create mode 100644 docker/sentinel_3/redis.conf create mode 100644 docker/slave/Dockerfile create mode 100644 docker/slave/redis.conf diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 898f5bfec4..0000000000 --- a/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM ubuntu:20.04 - -COPY build_tools /build_tools - -RUN /build_tools/bootstrap.sh -RUN /build_tools/build_redis.sh -RUN /build_tools/install_redis.sh -RUN /build_tools/install_sentinel.sh - -COPY build_tools/start.sh /var/lib/redis/start.sh - -EXPOSE 6379 -EXPOSE 6380 -EXPOSE 26379 -EXPOSE 26380 -EXPOSE 26381 - -CMD /var/lib/redis/start.sh diff --git a/docker-compose.yml b/docker-compose.yml index 9c61dd4cd8..f335f3c73e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,27 @@ version: "3.8" services: - redis-py: - build: ./ + master: + build: docker/master ports: - "6379:6379" + + slave: + build: docker/slave + ports: - "6380:6380" + + sentinel_1: + build: docker/sentinel_1 + ports: - "26379:26379" + + sentinel_2: + build: docker/sentinel_2 + ports: - "26380:26380" + + sentinel_3: + build: docker/sentinel_3 + ports: - "26381:26381" - volumes: - - .:/mnt/redis-py diff --git a/docker/master/Dockerfile b/docker/master/Dockerfile new file mode 100644 index 0000000000..c51249db3a --- /dev/null +++ b/docker/master/Dockerfile @@ -0,0 +1,7 @@ +FROM redis:6.0.5-buster + +COPY redis.conf /etc/conf/redis/redis.conf + +EXPOSE 6379 + +CMD redis-server diff --git a/docker/master/redis.conf b/docker/master/redis.conf new file mode 100644 index 0000000000..cd1c984a93 --- /dev/null +++ b/docker/master/redis.conf @@ -0,0 +1,9 @@ +pidfile /var/run/redis-master.pid +bind * +port 6379 +daemonize yes +unixsocket /tmp/redis_master.sock +unixsocketperm 777 +dbfilename master.rdb +dir /var/lib/redis/backups +logfile /var/log/redis-master diff --git a/docker/sentinel_1/Dockerfile b/docker/sentinel_1/Dockerfile new file mode 100644 index 0000000000..016d873f9b --- /dev/null +++ b/docker/sentinel_1/Dockerfile @@ -0,0 +1,7 @@ +FROM redis:6.0.5-buster + +COPY redis.conf /etc/conf/redis/redis.conf + +EXPOSE 26379 + +CMD redis-server diff --git a/docker/sentinel_1/redis.conf b/docker/sentinel_1/redis.conf new file mode 100644 index 0000000000..d12979fc0f --- /dev/null +++ b/docker/sentinel_1/redis.conf @@ -0,0 +1,7 @@ +pidfile /var/run/sentinel-1.pid +port 26379 +daemonize yes +logfile /var/log/redis-sentinel-1 + +# short timeout for sentinel tests +sentinel down-after-milliseconds mymaster 500 diff --git a/docker/sentinel_2/Dockerfile b/docker/sentinel_2/Dockerfile new file mode 100644 index 0000000000..1a379beb4e --- /dev/null +++ b/docker/sentinel_2/Dockerfile @@ -0,0 +1,7 @@ +FROM redis:6.0.5-buster + +COPY redis.conf /etc/conf/redis/redis.conf + +EXPOSE 26380 + +CMD redis-server diff --git a/docker/sentinel_2/redis.conf b/docker/sentinel_2/redis.conf new file mode 100644 index 0000000000..9ab89348a4 --- /dev/null +++ b/docker/sentinel_2/redis.conf @@ -0,0 +1,7 @@ +pidfile /var/run/sentinel-2.pid +port 26380 +daemonize yes +logfile /var/log/redis-sentinel-2 + +# short timeout for sentinel tests +sentinel down-after-milliseconds mymaster 500 diff --git a/docker/sentinel_3/Dockerfile b/docker/sentinel_3/Dockerfile new file mode 100644 index 0000000000..e1e92d8c16 --- /dev/null +++ b/docker/sentinel_3/Dockerfile @@ -0,0 +1,7 @@ +FROM redis:6.0.5-buster + +COPY redis.conf /etc/conf/redis/redis.conf + +EXPOSE 26381 + +CMD redis-server diff --git a/docker/sentinel_3/redis.conf b/docker/sentinel_3/redis.conf new file mode 100644 index 0000000000..b971b2ff45 --- /dev/null +++ b/docker/sentinel_3/redis.conf @@ -0,0 +1,7 @@ +pidfile /var/run/sentinel-3.pid +port 26381 +daemonize yes +logfile /var/log/redis-sentinel-3 + +# short timeout for sentinel tests +sentinel down-after-milliseconds mymaster 500 diff --git a/docker/slave/Dockerfile b/docker/slave/Dockerfile new file mode 100644 index 0000000000..f9bd1ad004 --- /dev/null +++ b/docker/slave/Dockerfile @@ -0,0 +1,7 @@ +FROM redis:6.0.5-buster + +COPY redis.conf /etc/conf/redis/redis.conf + +EXPOSE 6380 + +CMD redis-server diff --git a/docker/slave/redis.conf b/docker/slave/redis.conf new file mode 100644 index 0000000000..b51cb8464c --- /dev/null +++ b/docker/slave/redis.conf @@ -0,0 +1,11 @@ +pidfile /var/run/redis-slave.pid +bind * +port 6380 +daemonize yes +unixsocket /tmp/redis-slave.sock +unixsocketperm 777 +dbfilename slave.rdb +dir /var/lib/redis/backups +logfile /var/log/redis-slave + +slaveof 127.0.0.1 6379 From 08e99c6cba06946293f3ddb8fc72b78a9d3058fe Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 8 Jul 2020 16:55:07 -0700 Subject: [PATCH 0010/1164] Ignore venv and env directories --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 7de7594812..a9b10171fe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ vagrant/.vagrant .eggs .idea .coverage +env +venv From e7f9e3fce5d01929527d14b6b5938dd8daef8836 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 8 Jul 2020 16:55:19 -0700 Subject: [PATCH 0011/1164] Skip flake8 checks on virtualenv dirs --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index e57440c71f..6500cd872c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,7 +36,7 @@ python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* hiredis = hiredis>=0.1.3 [flake8] -exclude = .venv,.tox,dist,docs,build,*.egg,redis_install +exclude = .venv,.tox,dist,docs,build,*.egg,redis_install,env,venv [bdist_wheel] universal = 1 From bd1d7936c11b3fd750a3e48a2e554f2f0b317d19 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 8 Jul 2020 17:18:11 -0700 Subject: [PATCH 0012/1164] Revert build_tools to Vagrant versions --- build_tools/bootstrap.sh | 3 +- build_tools/build_redis.sh | 7 +++-- build_tools/install_redis.sh | 16 +++++++---- build_tools/install_sentinel.sh | 17 ++++++++---- build_tools/redis-configs/001-master | 1 - build_tools/redis-configs/002-slave | 1 - build_tools/redis_vars.sh | 41 +++++++++++++++++++++++++--- build_tools/sentinel-configs/001-1 | 1 - build_tools/sentinel-configs/002-2 | 1 - build_tools/sentinel-configs/003-3 | 1 - build_tools/start.sh | 9 ------ 11 files changed, 65 insertions(+), 33 deletions(-) delete mode 100755 build_tools/start.sh diff --git a/build_tools/bootstrap.sh b/build_tools/bootstrap.sh index 7125326de8..a5a0d2ce83 100755 --- a/build_tools/bootstrap.sh +++ b/build_tools/bootstrap.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash # need make to build redis -apt-get update -apt-get install -y make apt-utils wget build-essential +sudo apt-get install make diff --git a/build_tools/build_redis.sh b/build_tools/build_redis.sh index f530bf0860..379c6cc936 100755 --- a/build_tools/build_redis.sh +++ b/build_tools/build_redis.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash -source /build_tools/redis_vars.sh +source /home/vagrant/redis-py/build_tools/redis_vars.sh -pushd /tmp +pushd /home/vagrant + +uninstall_all_sentinel_instances +uninstall_all_redis_instances # create a clean directory for redis rm -rf $REDIS_DIR diff --git a/build_tools/install_redis.sh b/build_tools/install_redis.sh index 0e9d4b2259..fd53a1ca88 100755 --- a/build_tools/install_redis.sh +++ b/build_tools/install_redis.sh @@ -1,25 +1,28 @@ #!/usr/bin/env bash -source /build_tools/redis_vars.sh +source /home/vagrant/redis-py/build_tools/redis_vars.sh -for filename in `ls $BUILD_REDIS_CONF_DIR`; do +for filename in `ls $VAGRANT_REDIS_CONF_DIR`; do # cuts the order prefix off of the filename, e.g. 001-master -> master PROCESS_NAME=redis-`echo $filename | cut -f 2- -d -` echo "======================================" echo "INSTALLING REDIS SERVER: $PROCESS_NAME" echo "======================================" + # make sure the instance is uninstalled (it should be already) + uninstall_instance $PROCESS_NAME + # base config mkdir -p $REDIS_CONF_DIR cp $REDIS_BUILD_DIR/redis.conf $REDIS_CONF_DIR/$PROCESS_NAME.conf # override config values from file - cat $BUILD_REDIS_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf + cat $VAGRANT_REDIS_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf # replace placeholder variables in init.d script - cp $BUILD_DIR/redis_init_script /etc/init.d/$PROCESS_NAME + cp $VAGRANT_DIR/redis_init_script /etc/init.d/$PROCESS_NAME sed -i "s/{{ PROCESS_NAME }}/$PROCESS_NAME/g" /etc/init.d/$PROCESS_NAME # need to read the config file to find out what port this instance will run on - port=`grep port $BUILD_REDIS_CONF_DIR/$filename | cut -f 2 -d " "` + port=`grep port $VAGRANT_REDIS_CONF_DIR/$filename | cut -f 2 -d " "` sed -i "s/{{ PORT }}/$port/g" /etc/init.d/$PROCESS_NAME chmod 755 /etc/init.d/$PROCESS_NAME @@ -28,4 +31,7 @@ for filename in `ls $BUILD_REDIS_CONF_DIR`; do # save the $PROCESS_NAME into installed instances file echo $PROCESS_NAME >> $REDIS_INSTALLED_INSTANCES_FILE + + # start redis + /etc/init.d/$PROCESS_NAME start done diff --git a/build_tools/install_sentinel.sh b/build_tools/install_sentinel.sh index 0aa21effb9..0597208ccf 100755 --- a/build_tools/install_sentinel.sh +++ b/build_tools/install_sentinel.sh @@ -1,26 +1,28 @@ #!/usr/bin/env bash -source /build_tools/redis_vars.sh +source /home/vagrant/redis-py/build_tools/redis_vars.sh - -for filename in `ls $BUILD_SENTINEL_CONF_DIR`; do +for filename in `ls $VAGRANT_SENTINEL_CONF_DIR`; do # cuts the order prefix off of the filename, e.g. 001-master -> master PROCESS_NAME=sentinel-`echo $filename | cut -f 2- -d -` echo "=========================================" echo "INSTALLING SENTINEL SERVER: $PROCESS_NAME" echo "=========================================" + # make sure the instance is uninstalled (it should be already) + uninstall_instance $PROCESS_NAME + # base config mkdir -p $REDIS_CONF_DIR cp $REDIS_BUILD_DIR/sentinel.conf $REDIS_CONF_DIR/$PROCESS_NAME.conf # override config values from file - cat $BUILD_SENTINEL_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf + cat $VAGRANT_SENTINEL_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf # replace placeholder variables in init.d script - cp $BUILD_DIR/sentinel_init_script /etc/init.d/$PROCESS_NAME + cp $VAGRANT_DIR/sentinel_init_script /etc/init.d/$PROCESS_NAME sed -i "s/{{ PROCESS_NAME }}/$PROCESS_NAME/g" /etc/init.d/$PROCESS_NAME # need to read the config file to find out what port this instance will run on - port=`grep port $BUILD_SENTINEL_CONF_DIR/$filename | cut -f 2 -d " "` + port=`grep port $VAGRANT_SENTINEL_CONF_DIR/$filename | cut -f 2 -d " "` sed -i "s/{{ PORT }}/$port/g" /etc/init.d/$PROCESS_NAME chmod 755 /etc/init.d/$PROCESS_NAME @@ -29,4 +31,7 @@ for filename in `ls $BUILD_SENTINEL_CONF_DIR`; do # save the $PROCESS_NAME into installed instances file echo $PROCESS_NAME >> $SENTINEL_INSTALLED_INSTANCES_FILE + + # start redis + /etc/init.d/$PROCESS_NAME start done diff --git a/build_tools/redis-configs/001-master b/build_tools/redis-configs/001-master index cd1c984a93..8591f1a61e 100644 --- a/build_tools/redis-configs/001-master +++ b/build_tools/redis-configs/001-master @@ -6,4 +6,3 @@ unixsocket /tmp/redis_master.sock unixsocketperm 777 dbfilename master.rdb dir /var/lib/redis/backups -logfile /var/log/redis-master diff --git a/build_tools/redis-configs/002-slave b/build_tools/redis-configs/002-slave index b51cb8464c..13eb77ec4d 100644 --- a/build_tools/redis-configs/002-slave +++ b/build_tools/redis-configs/002-slave @@ -6,6 +6,5 @@ unixsocket /tmp/redis-slave.sock unixsocketperm 777 dbfilename slave.rdb dir /var/lib/redis/backups -logfile /var/log/redis-slave slaveof 127.0.0.1 6379 diff --git a/build_tools/redis_vars.sh b/build_tools/redis_vars.sh index b80ff5550e..c52dd4cf37 100755 --- a/build_tools/redis_vars.sh +++ b/build_tools/redis_vars.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -BUILD_DIR=/build_tools -BUILD_REDIS_CONF_DIR=$BUILD_DIR/redis-configs -BUILD_SENTINEL_CONF_DIR=$BUILD_DIR/sentinel-configs +VAGRANT_DIR=/home/vagrant/redis-py/build_tools +VAGRANT_REDIS_CONF_DIR=$VAGRANT_DIR/redis-configs +VAGRANT_SENTINEL_CONF_DIR=$VAGRANT_DIR/sentinel-configs REDIS_VERSION=3.2.0 -REDIS_DOWNLOAD_DIR=/tmp/redis-downloads +REDIS_DOWNLOAD_DIR=/home/vagrant/redis-downloads REDIS_PACKAGE=redis-$REDIS_VERSION.tar.gz REDIS_BUILD_DIR=$REDIS_DOWNLOAD_DIR/redis-$REDIS_VERSION REDIS_DIR=/var/lib/redis @@ -13,3 +13,36 @@ REDIS_CONF_DIR=$REDIS_DIR/conf REDIS_SAVE_DIR=$REDIS_DIR/backups REDIS_INSTALLED_INSTANCES_FILE=$REDIS_DIR/redis-instances SENTINEL_INSTALLED_INSTANCES_FILE=$REDIS_DIR/sentinel-instances + +function uninstall_instance() { + # Expects $1 to be the init.d filename, e.g. redis-nodename or + # sentinel-nodename + + if [ -a /etc/init.d/$1 ]; then + + echo "======================================" + echo "UNINSTALLING REDIS SERVER: $1" + echo "======================================" + + /etc/init.d/$1 stop + update-rc.d -f $1 remove + rm -f /etc/init.d/$1 + fi; + rm -f $REDIS_CONF_DIR/$1.conf +} + +function uninstall_all_redis_instances() { + if [ -a $REDIS_INSTALLED_INSTANCES_FILE ]; then + cat $REDIS_INSTALLED_INSTANCES_FILE | while read line; do + uninstall_instance $line; + done; + fi +} + +function uninstall_all_sentinel_instances() { + if [ -a $SENTINEL_INSTALLED_INSTANCES_FILE ]; then + cat $SENTINEL_INSTALLED_INSTANCES_FILE | while read line; do + uninstall_instance $line; + done; + fi +} diff --git a/build_tools/sentinel-configs/001-1 b/build_tools/sentinel-configs/001-1 index d12979fc0f..eccc3d1f84 100644 --- a/build_tools/sentinel-configs/001-1 +++ b/build_tools/sentinel-configs/001-1 @@ -1,7 +1,6 @@ pidfile /var/run/sentinel-1.pid port 26379 daemonize yes -logfile /var/log/redis-sentinel-1 # short timeout for sentinel tests sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/sentinel-configs/002-2 b/build_tools/sentinel-configs/002-2 index 9ab89348a4..0cd28019c4 100644 --- a/build_tools/sentinel-configs/002-2 +++ b/build_tools/sentinel-configs/002-2 @@ -1,7 +1,6 @@ pidfile /var/run/sentinel-2.pid port 26380 daemonize yes -logfile /var/log/redis-sentinel-2 # short timeout for sentinel tests sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/sentinel-configs/003-3 b/build_tools/sentinel-configs/003-3 index b971b2ff45..c7f4fcd335 100644 --- a/build_tools/sentinel-configs/003-3 +++ b/build_tools/sentinel-configs/003-3 @@ -1,7 +1,6 @@ pidfile /var/run/sentinel-3.pid port 26381 daemonize yes -logfile /var/log/redis-sentinel-3 # short timeout for sentinel tests sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/start.sh b/build_tools/start.sh deleted file mode 100755 index c394b949aa..0000000000 --- a/build_tools/start.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -/etc/init.d/redis-master start -/etc/init.d/redis-slave start -/etc/init.d/sentinel-1 start -/etc/init.d/sentinel-2 start -/etc/init.d/sentinel-3 start - -tail -f /var/log/redis-master /var/log/redis-slave /var/log/redis-sentinel-1 /var/log/redis-sentinel-2 /var/log/redis-sentinel-3 From 58ca166a9c7228f24fdd074c4785fe7303851cd4 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 9 Jul 2020 14:53:00 -0700 Subject: [PATCH 0013/1164] WIP on base image --- Makefile | 12 ++++++++++++ docker-compose.yml | 6 ++++++ docker/base/Dockerfile | 1 + docker/master/Dockerfile | 2 +- docker/sentinel_1/Dockerfile | 2 +- docker/sentinel_2/Dockerfile | 2 +- docker/sentinel_3/Dockerfile | 2 +- docker/slave/Dockerfile | 2 +- tests/conftest.py | 11 +++++++++-- 9 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 Makefile create mode 100644 docker/base/Dockerfile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..fb3e0b6ab6 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: base + +build: + docker build -t redis-py-base docker/base + docker-compose down + docker-compose build + +dev: + docker-compose up -d + +test: dev + docker-compose run test tox --redis-url="redis://master:6379/9" diff --git a/docker-compose.yml b/docker-compose.yml index f335f3c73e..c7a7c0a2b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,3 +25,9 @@ services: build: docker/sentinel_3 ports: - "26381:26381" + + test: + image: fkrull/multi-python:latest + working_dir: /redis-py + volumes: + - .:/redis-py diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile new file mode 100644 index 0000000000..ab973861da --- /dev/null +++ b/docker/base/Dockerfile @@ -0,0 +1 @@ +FROM redis:6.0.5-buster diff --git a/docker/master/Dockerfile b/docker/master/Dockerfile index c51249db3a..af81fb30c0 100644 --- a/docker/master/Dockerfile +++ b/docker/master/Dockerfile @@ -1,4 +1,4 @@ -FROM redis:6.0.5-buster +FROM redis-py-base:latest COPY redis.conf /etc/conf/redis/redis.conf diff --git a/docker/sentinel_1/Dockerfile b/docker/sentinel_1/Dockerfile index 016d873f9b..e3c3e7b16d 100644 --- a/docker/sentinel_1/Dockerfile +++ b/docker/sentinel_1/Dockerfile @@ -1,4 +1,4 @@ -FROM redis:6.0.5-buster +FROM redis-py-base:latest COPY redis.conf /etc/conf/redis/redis.conf diff --git a/docker/sentinel_2/Dockerfile b/docker/sentinel_2/Dockerfile index 1a379beb4e..cd59777d3c 100644 --- a/docker/sentinel_2/Dockerfile +++ b/docker/sentinel_2/Dockerfile @@ -1,4 +1,4 @@ -FROM redis:6.0.5-buster +FROM redis-py-base:latest COPY redis.conf /etc/conf/redis/redis.conf diff --git a/docker/sentinel_3/Dockerfile b/docker/sentinel_3/Dockerfile index e1e92d8c16..2e81a15500 100644 --- a/docker/sentinel_3/Dockerfile +++ b/docker/sentinel_3/Dockerfile @@ -1,4 +1,4 @@ -FROM redis:6.0.5-buster +FROM redis-py-base:latest COPY redis.conf /etc/conf/redis/redis.conf diff --git a/docker/slave/Dockerfile b/docker/slave/Dockerfile index f9bd1ad004..74c1fb0903 100644 --- a/docker/slave/Dockerfile +++ b/docker/slave/Dockerfile @@ -1,4 +1,4 @@ -FROM redis:6.0.5-buster +FROM redis-py-base:latest COPY redis.conf /etc/conf/redis/redis.conf diff --git a/tests/conftest.py b/tests/conftest.py index 7d64609be8..4398175b1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,14 +14,21 @@ REDIS_INFO = {} -default_redis_url = "redis://localhost:6379/9" +DEFAULT_REDIS_URL = "redis://localhost:6379/9" +DEFAULT_REDIS_MASTER_HOST = "localhost" + def pytest_addoption(parser): - parser.addoption('--redis-url', default=default_redis_url, + parser.addoption('--redis-url', default=DEFAULT_REDIS_URL, action="store", help="Redis connection string," " defaults to `%(default)s`") + parser.addoption('--master-host', default=DEFAULT_REDIS_MASTER_HOST, + action="store", + help="Redis master hostname," + " defaults to `%(default)s`") + def _get_info(redis_url): From c31ea12e89141dd90a69180060ae386948831f19 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 9 Jul 2020 16:15:23 -0700 Subject: [PATCH 0014/1164] Pass the master hostname to tests --- Makefile | 3 ++- tests/conftest.py | 7 +++++- tests/test_connection_pool.py | 42 ++++++++++++++++++++++------------- tests/test_multiprocessing.py | 16 ++++++------- tests/test_sentinel.py | 35 +++++++++++++++++------------ 5 files changed, 63 insertions(+), 40 deletions(-) diff --git a/Makefile b/Makefile index fb3e0b6ab6..ad1eb55f8d 100644 --- a/Makefile +++ b/Makefile @@ -9,4 +9,5 @@ dev: docker-compose up -d test: dev - docker-compose run test tox --redis-url="redis://master:6379/9" + find . -name "*.pyc" -exec rm -f {} \; + docker-compose run test tox -- --redis-url="redis://master:6379/9" --redis-master-host=master diff --git a/tests/conftest.py b/tests/conftest.py index 4398175b1e..4a745df005 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ def pytest_addoption(parser): action="store", help="Redis connection string," " defaults to `%(default)s`") - parser.addoption('--master-host', default=DEFAULT_REDIS_MASTER_HOST, + parser.addoption('--redis-master-host', default=DEFAULT_REDIS_MASTER_HOST, action="store", help="Redis master hostname," " defaults to `%(default)s`") @@ -163,6 +163,11 @@ def mock_cluster_resp_slaves(request, **kwargs): return _gen_cluster_mock_resp(r, response) +@pytest.fixture(scope="module") +def master_host(request): + yield request.config.getoption("--redis-master-host") + + def wait_for_command(client, monitor, command): # issue a command with a key name that's local to this process. # if we find a command with our key before the command we're waiting diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 080e3df5b5..2f8ffbf034 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -43,21 +43,25 @@ def test_connection_creation(self): assert isinstance(connection, DummyConnection) assert connection.kwargs == connection_kwargs - def test_multiple_connections(self): - pool = self.get_pool() + def test_multiple_connections(self, master_host): + connection_kwargs = {'host': master_host} + pool = self.get_pool(connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') c2 = pool.get_connection('_') assert c1 != c2 - def test_max_connections(self): - pool = self.get_pool(max_connections=2) + def test_max_connections(self, master_host): + connection_kwargs = {'host': master_host} + pool = self.get_pool(max_connections=2, + connection_kwargs=connection_kwargs) pool.get_connection('_') pool.get_connection('_') with pytest.raises(redis.ConnectionError): pool.get_connection('_') - def test_reuse_previously_released_connection(self): - pool = self.get_pool() + def test_reuse_previously_released_connection(self, master_host): + connection_kwargs = {'host': master_host} + pool = self.get_pool(connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') pool.release(c1) c2 = pool.get_connection('_') @@ -98,22 +102,25 @@ def get_pool(self, connection_kwargs=None, max_connections=10, timeout=20): **connection_kwargs) return pool - def test_connection_creation(self): - connection_kwargs = {'foo': 'bar', 'biz': 'baz'} + def test_connection_creation(self, master_host): + connection_kwargs = {'foo': 'bar', 'biz': 'baz', 'host': master_host} pool = self.get_pool(connection_kwargs=connection_kwargs) connection = pool.get_connection('_') assert isinstance(connection, DummyConnection) assert connection.kwargs == connection_kwargs - def test_multiple_connections(self): - pool = self.get_pool() + def test_multiple_connections(self, master_host): + connection_kwargs = {'host': master_host} + pool = self.get_pool(connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') c2 = pool.get_connection('_') assert c1 != c2 - def test_connection_pool_blocks_until_timeout(self): + def test_connection_pool_blocks_until_timeout(self, master_host): "When out of connections, block for timeout seconds, then raise" - pool = self.get_pool(max_connections=1, timeout=0.1) + connection_kwargs = {'host': master_host} + pool = self.get_pool(max_connections=1, timeout=0.1, + connection_kwargs=connection_kwargs) pool.get_connection('_') start = time.time() @@ -122,12 +129,14 @@ def test_connection_pool_blocks_until_timeout(self): # we should have waited at least 0.1 seconds assert time.time() - start >= 0.1 - def connection_pool_blocks_until_another_connection_released(self): + def test_connection_pool_blocks_until_another_connection_released(self, master_host): """ When out of connections, block until another connection is released to the pool """ - pool = self.get_pool(max_connections=1, timeout=2) + connection_kwargs = {'host': master_host} + pool = self.get_pool(max_connections=1, timeout=2, + connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') def target(): @@ -139,8 +148,9 @@ def target(): pool.get_connection('_') assert time.time() - start >= 0.1 - def test_reuse_previously_released_connection(self): - pool = self.get_pool() + def test_reuse_previously_released_connection(self, master_host): + connection_kwargs = {'host': master_host} + pool = self.get_pool(connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') pool.release(c1) c2 = pool.get_connection('_') diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index 3f81606836..235e3cee0f 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -30,12 +30,12 @@ def r(self, request): request=request, single_connection_client=False) - def test_close_connection_in_child(self): + def test_close_connection_in_child(self, master_host): """ A connection owned by a parent and closed by a child doesn't destroy the file descriptors so a parent can still use it. """ - conn = Connection() + conn = Connection(host=master_host) conn.send_command('ping') assert conn.read_response() == b'PONG' @@ -56,12 +56,12 @@ def target(conn): conn.send_command('ping') assert conn.read_response() == b'PONG' - def test_close_connection_in_parent(self): + def test_close_connection_in_parent(self, master_host): """ A connection owned by a parent is unusable by a child if the parent (the owning process) closes the connection. """ - conn = Connection() + conn = Connection(host=master_host) conn.send_command('ping') assert conn.read_response() == b'PONG' @@ -84,12 +84,12 @@ def target(conn, ev): assert proc.exitcode == 0 @pytest.mark.parametrize('max_connections', [1, 2, None]) - def test_pool(self, max_connections): + def test_pool(self, max_connections, master_host): """ A child will create its own connections when using a pool created by a parent. """ - pool = ConnectionPool.from_url('redis://localhost', + pool = ConnectionPool.from_url('redis://{}'.format(master_host), max_connections=max_connections) conn = pool.get_connection('ping') @@ -119,12 +119,12 @@ def target(pool): assert conn.read_response() == b'PONG' @pytest.mark.parametrize('max_connections', [1, 2, None]) - def test_close_pool_in_main(self, max_connections): + def test_close_pool_in_main(self, max_connections, master_host): """ A child process that uses the same pool as its parent isn't affected when the parent disconnects all connections within the pool. """ - pool = ConnectionPool.from_url('redis://localhost', + pool = ConnectionPool.from_url('redis://{}'.format(master_host), max_connections=max_connections) conn = pool.get_connection('ping') diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py index 1081e2b3f3..c247c723bf 100644 --- a/tests/test_sentinel.py +++ b/tests/test_sentinel.py @@ -1,3 +1,5 @@ +import socket + import pytest from redis import exceptions @@ -7,6 +9,11 @@ import redis.sentinel +@pytest.fixture(scope="module") +def master_ip(master_host): + yield socket.gethostbyname(master_host) + + class SentinelTestClient(object): def __init__(self, cluster, id): self.cluster = cluster @@ -54,10 +61,10 @@ def client(self, host, port, **kwargs): @pytest.fixture() -def cluster(request): +def cluster(request, master_ip): def teardown(): redis.sentinel.Redis = saved_Redis - cluster = SentinelTestCluster() + cluster = SentinelTestCluster(ip=master_ip) saved_Redis = redis.sentinel.Redis redis.sentinel.Redis = cluster.client request.addfinalizer(teardown) @@ -69,9 +76,9 @@ def sentinel(request, cluster): return Sentinel([('foo', 26379), ('bar', 26379)]) -def test_discover_master(sentinel): +def test_discover_master(sentinel, master_ip): address = sentinel.discover_master('mymaster') - assert address == ('127.0.0.1', 6379) + assert address == (master_ip, 6379) def test_discover_master_error(sentinel): @@ -79,32 +86,32 @@ def test_discover_master_error(sentinel): sentinel.discover_master('xxx') -def test_discover_master_sentinel_down(cluster, sentinel): +def test_discover_master_sentinel_down(cluster, sentinel, master_ip): # Put first sentinel 'foo' down cluster.nodes_down.add(('foo', 26379)) address = sentinel.discover_master('mymaster') - assert address == ('127.0.0.1', 6379) + assert address == (master_ip, 6379) # 'bar' is now first sentinel assert sentinel.sentinels[0].id == ('bar', 26379) -def test_discover_master_sentinel_timeout(cluster, sentinel): +def test_discover_master_sentinel_timeout(cluster, sentinel, master_ip): # Put first sentinel 'foo' down cluster.nodes_timeout.add(('foo', 26379)) address = sentinel.discover_master('mymaster') - assert address == ('127.0.0.1', 6379) + assert address == (master_ip, 6379) # 'bar' is now first sentinel assert sentinel.sentinels[0].id == ('bar', 26379) -def test_master_min_other_sentinels(cluster): +def test_master_min_other_sentinels(cluster, master_ip): sentinel = Sentinel([('foo', 26379)], min_other_sentinels=1) # min_other_sentinels with pytest.raises(MasterNotFoundError): sentinel.discover_master('mymaster') cluster.master['num-other-sentinels'] = 2 address = sentinel.discover_master('mymaster') - assert address == ('127.0.0.1', 6379) + assert address == (master_ip, 6379) def test_master_odown(cluster, sentinel): @@ -153,10 +160,10 @@ def test_discover_slaves(cluster, sentinel): ('slave0', 1234), ('slave1', 1234)] -def test_master_for(cluster, sentinel): +def test_master_for(cluster, sentinel, master_ip): master = sentinel.master_for('mymaster', db=9) assert master.ping() - assert master.connection_pool.master_address == ('127.0.0.1', 6379) + assert master.connection_pool.master_address == (master_ip, 6379) # Use internal connection check master = sentinel.master_for('mymaster', db=9, check_connection=True) @@ -179,7 +186,7 @@ def test_slave_for_slave_not_found_error(cluster, sentinel): slave.ping() -def test_slave_round_robin(cluster, sentinel): +def test_slave_round_robin(cluster, sentinel, master_ip): cluster.slaves = [ {'ip': 'slave0', 'port': 6379, 'is_odown': False, 'is_sdown': False}, {'ip': 'slave1', 'port': 6379, 'is_odown': False, 'is_sdown': False}, @@ -189,6 +196,6 @@ def test_slave_round_robin(cluster, sentinel): assert next(rotator) in (('slave0', 6379), ('slave1', 6379)) assert next(rotator) in (('slave0', 6379), ('slave1', 6379)) # Fallback to master - assert next(rotator) == ('127.0.0.1', 6379) + assert next(rotator) == (master_ip, 6379) with pytest.raises(SlaveNotFoundError): next(rotator) From a1e5a7685fda4fc8ebe6456988c18e14724bb05a Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 9 Jul 2020 16:30:10 -0700 Subject: [PATCH 0015/1164] Fix flake8 errors --- setup.cfg | 2 +- tests/conftest.py | 2 -- tests/test_connection_pool.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index 6500cd872c..430cba081e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,7 +36,7 @@ python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* hiredis = hiredis>=0.1.3 [flake8] -exclude = .venv,.tox,dist,docs,build,*.egg,redis_install,env,venv +exclude = .venv,.tox,dist,docs,build,*.egg,redis_install,env,venv,.undodir [bdist_wheel] universal = 1 diff --git a/tests/conftest.py b/tests/conftest.py index 4a745df005..704a7e37eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,6 @@ DEFAULT_REDIS_MASTER_HOST = "localhost" - def pytest_addoption(parser): parser.addoption('--redis-url', default=DEFAULT_REDIS_URL, action="store", @@ -30,7 +29,6 @@ def pytest_addoption(parser): " defaults to `%(default)s`") - def _get_info(redis_url): client = redis.Redis.from_url(redis_url) info = client.info() diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 2f8ffbf034..40ac3407db 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -129,7 +129,7 @@ def test_connection_pool_blocks_until_timeout(self, master_host): # we should have waited at least 0.1 seconds assert time.time() - start >= 0.1 - def test_connection_pool_blocks_until_another_connection_released(self, master_host): + def test_connection_pool_blocks_until_another_connection_released(self, master_host): # noqa: E501 """ When out of connections, block until another connection is released to the pool From e0460b2f5004d068b83a4b701413d9ac64e0e04f Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 10 Jul 2020 10:54:16 -0700 Subject: [PATCH 0016/1164] Use the existing --redis-url param to get master host --- Makefile | 2 +- tests/conftest.py | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index ad1eb55f8d..1b29336489 100644 --- a/Makefile +++ b/Makefile @@ -10,4 +10,4 @@ dev: test: dev find . -name "*.pyc" -exec rm -f {} \; - docker-compose run test tox -- --redis-url="redis://master:6379/9" --redis-master-host=master + docker-compose run test tox -- --redis-url=redis://master:6379/9 diff --git a/tests/conftest.py b/tests/conftest.py index 704a7e37eb..caca8cc51d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import redis from mock import Mock +from redis._compat import urlparse from distutils.version import StrictVersion @@ -14,19 +15,14 @@ REDIS_INFO = {} -DEFAULT_REDIS_URL = "redis://localhost:6379/9" -DEFAULT_REDIS_MASTER_HOST = "localhost" +default_redis_url = "redis://localhost:6379/9" def pytest_addoption(parser): - parser.addoption('--redis-url', default=DEFAULT_REDIS_URL, + parser.addoption('--redis-url', default=default_redis_url, action="store", help="Redis connection string," " defaults to `%(default)s`") - parser.addoption('--redis-master-host', default=DEFAULT_REDIS_MASTER_HOST, - action="store", - help="Redis master hostname," - " defaults to `%(default)s`") def _get_info(redis_url): @@ -161,9 +157,11 @@ def mock_cluster_resp_slaves(request, **kwargs): return _gen_cluster_mock_resp(r, response) -@pytest.fixture(scope="module") +@pytest.fixture(scope="session") def master_host(request): - yield request.config.getoption("--redis-master-host") + url = request.config.getoption("--redis-url") + parts = urlparse(url) + yield parts.hostname def wait_for_command(client, monitor, command): From b5c2f2e650d152c5f1da5f5e3be9ebe4189972a4 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 10 Jul 2020 11:26:36 -0700 Subject: [PATCH 0017/1164] Don't shut down containers when building --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 1b29336489..f86cbad5e3 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,6 @@ build: docker build -t redis-py-base docker/base - docker-compose down docker-compose build dev: From cec8e77b510eae7720cdf7258deb0a0085a9af12 Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Mon, 13 Jul 2020 19:41:29 +0300 Subject: [PATCH 0018/1164] Support for loading, unloading and listing Redis Modules (#1360) * Support for loading, unloading and listing Redis Modules * minor fixes for flake * unit test for module list - only the empty use case * ModuleError should inherit from ResponseError rather than RedisError Co-authored-by: Vamsi Atluri --- redis/client.py | 33 +++++++++++++++++++++++++++++++++ redis/connection.py | 13 +++++++++++++ redis/exceptions.py | 4 ++++ tests/test_commands.py | 5 +++++ 4 files changed, 55 insertions(+) diff --git a/redis/client.py b/redis/client.py index 9de90fbd9a..9653f7d294 100755 --- a/redis/client.py +++ b/redis/client.py @@ -22,6 +22,7 @@ ResponseError, TimeoutError, WatchError, + ModuleError, ) SYM_EMPTY = b'' @@ -516,6 +517,12 @@ def parse_acl_getuser(response, **options): return data +def parse_module_result(response): + if isinstance(response, ModuleError): + raise response + return True + + class Redis(object): """ Implementation of the Redis protocol. @@ -656,6 +663,10 @@ class Redis(object): 'XPENDING': parse_xpending, 'ZADD': parse_zadd, 'ZSCAN': parse_zscan, + 'MODULE LOAD': parse_module_result, + 'MODULE UNLOAD': parse_module_result, + 'MODULE LIST': lambda response: [pairs_to_dict(module) + for module in response], } ) @@ -3306,6 +3317,28 @@ def _georadiusgeneric(self, command, *args, **kwargs): return self.execute_command(command, *pieces, **kwargs) + # MODULE COMMANDS + def module_load(self, path): + """ + Loads the module from ``path``. + Raises ``ModuleError`` if a module is not found at ``path``. + """ + return self.execute_command('MODULE LOAD', path) + + def module_unload(self, name): + """ + Unloads the module ``name``. + Raises ``ModuleError`` if ``name`` is not in loaded modules. + """ + return self.execute_command('MODULE UNLOAD', name) + + def module_list(self): + """ + Returns a list of dictionaries containing the name and version of + all loaded modules. + """ + return self.execute_command('MODULE LIST') + StrictRedis = Redis diff --git a/redis/connection.py b/redis/connection.py index e3c9b66287..b08aee9cd5 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -29,6 +29,7 @@ RedisError, ResponseError, TimeoutError, + ModuleError, ) from redis.utils import HIREDIS_AVAILABLE @@ -90,6 +91,14 @@ SERVER_CLOSED_CONNECTION_ERROR = "Connection closed by server." SENTINEL = object() +MODULE_LOAD_ERROR = 'Error loading the extension. ' \ + 'Please check the server logs.' +NO_SUCH_MODULE_ERROR = 'Error unloading module: no such module with that name' +MODULE_UNLOAD_NOT_POSSIBLE_ERROR = 'Error unloading module: operation not ' \ + 'possible.' +MODULE_EXPORTS_DATA_TYPES_ERROR = "Error unloading module: the module " \ + "exports one or more module-side data " \ + "types, can't unload" class Encoder(object): @@ -146,6 +155,10 @@ class BaseParser(object): # in uppercase 'wrong number of arguments for \'AUTH\' command': AuthenticationWrongNumberOfArgsError, + MODULE_LOAD_ERROR: ModuleError, + MODULE_EXPORTS_DATA_TYPES_ERROR: ModuleError, + NO_SUCH_MODULE_ERROR: ModuleError, + MODULE_UNLOAD_NOT_POSSIBLE_ERROR: ModuleError, }, 'EXECABORT': ExecAbortError, 'LOADING': BusyLoadingError, diff --git a/redis/exceptions.py b/redis/exceptions.py index 760af66f52..ca2ad870b6 100644 --- a/redis/exceptions.py +++ b/redis/exceptions.py @@ -57,6 +57,10 @@ class NoPermissionError(ResponseError): pass +class ModuleError(ResponseError): + pass + + class LockError(RedisError, ValueError): "Errors acquiring or releasing a lock" # NOTE: For backwards compatability, this class derives from ValueError. diff --git a/tests/test_commands.py b/tests/test_commands.py index 65e877ca7a..adaa9fcb55 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2629,6 +2629,11 @@ def test_memory_usage(self, r): r.set('foo', 'bar') assert isinstance(r.memory_usage('foo'), int) + @skip_if_server_version_lt('4.0.0') + def test_module_list(self, r): + assert isinstance(r.module_list(), list) + assert not r.module_list() + class TestBinarySave(object): From 142a44a075df4f371320d6da2b8341db62fa5d15 Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Mon, 13 Jul 2020 20:20:59 +0300 Subject: [PATCH 0019/1164] fix typo (#1367) --- redis/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/exceptions.py b/redis/exceptions.py index ca2ad870b6..91eb3c7257 100644 --- a/redis/exceptions.py +++ b/redis/exceptions.py @@ -63,7 +63,7 @@ class ModuleError(ResponseError): class LockError(RedisError, ValueError): "Errors acquiring or releasing a lock" - # NOTE: For backwards compatability, this class derives from ValueError. + # NOTE: For backwards compatibility, this class derives from ValueError. # This was originally chosen to behave like threading.Lock. pass From 61ece12e785ee70871dbc04d41ca5e760d8c401f Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 18:59:25 +0000 Subject: [PATCH 0020/1164] Add guide to contributing --- CONTRIBUTING.rst | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ README.rst | 6 ++++ 2 files changed, 86 insertions(+) create mode 100644 CONTRIBUTING.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 0000000000..6823540617 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,80 @@ +Contributing +============ + +Introduction +------------ + +First off, thank you for considering contributing to redis-py. We value community contributions! + +What Kinds of Contributions We Need +----------------------------------- + +You may already know what you want to contribute -- a fix for a bug you encountered, or a new feature your team wants to use. + +If you don't know what to contribute, keep an open mind! Improving documentation, bug triaging, or writing tutorials are all examples of helpful contributions that mean less work for you. + +Contributions We are Not Looking For +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Please, don't use the issue tracker for support questions. Check whether the #redis-py IRC channel on Freenode can help with your issue. If your problem is not strictly #redis-py specific, #redis is generally more active. Stack Overflow is also worth considering. + +Your First Contribution +----------------------- +Unsure where to begin contributing to Atom? You can start by looking through help-wanted issues: https://github.com/andymccurdy/redis-py/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted + +Never contributed to open source before? Here are a couple of friendly tutorials: + +- http://makeapullrequest.com/ +- http://www.firsttimersonly.com/ +- https://github.com/andymccurdy/redis-py/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted + +Getting Started +--------------- + +Here's how to get started with your code contribution: + +1. Create your own fork of redis-py +2. When you've checked out the fork locally, build the docker containers: `make build` +2. Do the changes in your fork +3. Make sure the tests pass by running: `make test` +4. If you like the change and think the project could use it, send a pull request + +How to Report a Bug +------------------- + +Security Vulnerabilities +^^^^^^^^^^^^^^^^^^^^^^^^ + +**NOTE**: If you find a security vulnerability, do NOT open an issue. Email Andy McCurdy (sedrik@gmail.com) instead. + +In order to determine whether you are dealing with a security issue, ask yourself these two questions: + +* Can I access something that's not mine, or something I shouldn't have access to? +* Can I disable something for other people? + +If the answer to either of those two questions are "yes", then you're probably dealing with a security issue. Note that even if you answer "no" to both questions, you may still be dealing with a security issue, so if you're unsure, just email Andy at sedrik@gmail.com. + +Everything Else +^^^^^^^^^^^^^^^ + +When filing an issue, make sure to answer these five questions: + +1. What version of redis-py are you using? +2. What version of redis are you using? +3. What did you do? +4. What did you expect to see? +5. What did you see instead? + +General questions should go to the redis-py mailing list instead of the issue tracker. + +How to Suggest a Feature or Enhancement +--------------------------------------- + +If you'd like to contribute a new feature, make sure you check our issue list to see if someone has already proposed it. Work may already be under way on the feature you want -- or we may have rejected a feature like it already. + +If you don't see anything, open a new issue that describes the feature you would like and how it should work. + +Code Review Process +------------------- + +The core team looks at Pull Requests on a regular basis. We will give feedback as as soon as possible. After feedback, we expect a response within two weeks. After that time, we may close your PR if it isn't showing any activity. diff --git a/README.rst b/README.rst index 3f8de91466..4f2cec840b 100644 --- a/README.rst +++ b/README.rst @@ -47,6 +47,12 @@ or from source: $ python setup.py install +Contributing +------------ + +Want to contribute a feature, bug report, or report an issue? Check out our `guide to +contributing `_. + Getting Started --------------- From c5c9e81a40d8899b3f7c1ca72cdd7502dfc3fa8c Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 19:02:54 +0000 Subject: [PATCH 0021/1164] Remove references to maling lists and IRC channels --- CONTRIBUTING.rst | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 6823540617..7e623c4599 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -6,27 +6,21 @@ Introduction First off, thank you for considering contributing to redis-py. We value community contributions! -What Kinds of Contributions We Need ------------------------------------ +Contributions We Need +---------------------- You may already know what you want to contribute -- a fix for a bug you encountered, or a new feature your team wants to use. If you don't know what to contribute, keep an open mind! Improving documentation, bug triaging, or writing tutorials are all examples of helpful contributions that mean less work for you. -Contributions We are Not Looking For -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Please, don't use the issue tracker for support questions. Check whether the #redis-py IRC channel on Freenode can help with your issue. If your problem is not strictly #redis-py specific, #redis is generally more active. Stack Overflow is also worth considering. - Your First Contribution ----------------------- -Unsure where to begin contributing to Atom? You can start by looking through help-wanted issues: https://github.com/andymccurdy/redis-py/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted +Unsure where to begin contributing? You can start by looking through help-wanted issues: https://github.com/andymccurdy/redis-py/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted Never contributed to open source before? Here are a couple of friendly tutorials: - http://makeapullrequest.com/ - http://www.firsttimersonly.com/ -- https://github.com/andymccurdy/redis-py/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted Getting Started --------------- @@ -65,8 +59,6 @@ When filing an issue, make sure to answer these five questions: 4. What did you expect to see? 5. What did you see instead? -General questions should go to the redis-py mailing list instead of the issue tracker. - How to Suggest a Feature or Enhancement --------------------------------------- From af3771b1479caec3e7396b952cb78a60a6e98253 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 19:06:14 +0000 Subject: [PATCH 0022/1164] Attempt to use docker for travis testing --- .travis.yml | 46 +++++++--------------------------------------- 1 file changed, 7 insertions(+), 39 deletions(-) diff --git a/.travis.yml b/.travis.yml index 05dd94c25f..615a0c9ed8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,42 +1,10 @@ language: python -cache: pip -matrix: - include: - - env: TOXENV=flake8 - - python: 2.7 - env: TOXENV=py27-plain - - python: 2.7 - env: TOXENV=py27-hiredis - - python: 3.5 - env: TOXENV=py35-plain - - python: 3.5 - env: TOXENV=py35-hiredis - - python: 3.6 - env: TOXENV=py36-plain - - python: 3.6 - env: TOXENV=py36-hiredis - - python: 3.7 - env: TOXENV=py37-plain - - python: 3.7 - env: TOXENV=py37-hiredis - - python: 3.8 - env: TOXENV=py38-plain - - python: 3.8 - env: TOXENV=py38-hiredis - - python: pypy - env: TOXENV=pypy-plain - - python: pypy - env: TOXENV=pypy-hiredis - - python: pypy3 - env: TOXENV=pypy3-plain - - python: pypy3 - env: TOXENV=pypy3-hiredis + +services: + - docker + before_install: - - wget https://github.com/antirez/redis/archive/6.0.5.tar.gz && mkdir redis_install && tar -xvzf 6.0.5.tar.gz -C redis_install && cd redis_install/redis-6.0.5 && make && src/redis-server --daemonize yes && cd ../.. - - redis-cli info -install: - - pip install codecov tox + - make build + script: - - tox -after_success: - - "if [[ $TOXENV != 'flake8' ]]; then codecov; fi" + - make test From b4b1d9702964aee9ba38d16a067cf5dbc21d5d44 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 19:09:10 +0000 Subject: [PATCH 0023/1164] WIP on travis config --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 615a0c9ed8..8b51c30777 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ language: python +env: + - DOCKER_COMPOSE_VERSION=1.26.2 + services: - docker From 867b28d341b42124e4af7e787312354544391183 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 19:14:45 +0000 Subject: [PATCH 0024/1164] Install the version of compose that we need --- .travis.yml | 6 ++++++ CONTRIBUTING.rst | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.travis.yml b/.travis.yml index 8b51c30777..62e01729c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,12 @@ language: python env: - DOCKER_COMPOSE_VERSION=1.26.2 +before_install: + - sudo rm /usr/local/bin/docker-compose + - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose + - chmod +x docker-compose + - sudo mv docker-compose /usr/local/bin + services: - docker diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 7e623c4599..177dfaa3fc 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -33,6 +33,18 @@ Here's how to get started with your code contribution: 3. Make sure the tests pass by running: `make test` 4. If you like the change and think the project could use it, send a pull request +Troubleshooting +^^^^^^^^^^^^^^^ + +If you get any errors when running `make build` or `make test`, make sure that you +are using supported versions of Docker and docker-compose. + +The included Dockerfiles and docker-compose.yml file work with the following +versions of Docker and docker-compose: + +* Docker 19.03.12 +* docker-compose 1.26.2 + How to Report a Bug ------------------- From e01cc2f549a01e865f6898632e1532997a9e6401 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 19:20:14 +0000 Subject: [PATCH 0025/1164] WIP --- .travis.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 62e01729c7..bb943738e4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,8 +12,6 @@ before_install: services: - docker -before_install: - - make build - script: + - make build - make test From e4954ba0b7eb7b2250c7d32a1707851ad19ae12f Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 19:27:31 +0000 Subject: [PATCH 0026/1164] No need for Python in the test build anymore --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index bb943738e4..0e3b1f9496 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,3 @@ -language: python - env: - DOCKER_COMPOSE_VERSION=1.26.2 From 27c4af489db75bf264407e1135ac0e42c9e8ee96 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 19:32:37 +0000 Subject: [PATCH 0027/1164] Fix numbering --- CONTRIBUTING.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 177dfaa3fc..90a00eb228 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -29,9 +29,9 @@ Here's how to get started with your code contribution: 1. Create your own fork of redis-py 2. When you've checked out the fork locally, build the docker containers: `make build` -2. Do the changes in your fork -3. Make sure the tests pass by running: `make test` -4. If you like the change and think the project could use it, send a pull request +3. Do the changes in your fork +4. Make sure the tests pass by running: `make test` +5. If you like the change and think the project could use it, send a pull request Troubleshooting ^^^^^^^^^^^^^^^ From 266e2066e1e6ae46c8830dc049c89d50bf463231 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 19:35:51 +0000 Subject: [PATCH 0028/1164] Format CLI commands correctly for RST --- CONTRIBUTING.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 90a00eb228..c6990f224c 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -28,15 +28,15 @@ Getting Started Here's how to get started with your code contribution: 1. Create your own fork of redis-py -2. When you've checked out the fork locally, build the docker containers: `make build` +2. When you've checked out the fork locally, build the docker containers: ``make build`` 3. Do the changes in your fork -4. Make sure the tests pass by running: `make test` +4. Make sure the tests pass by running: ``make test`` 5. If you like the change and think the project could use it, send a pull request Troubleshooting ^^^^^^^^^^^^^^^ -If you get any errors when running `make build` or `make test`, make sure that you +If you get any errors when running ``make build`` or ``make test``, make sure that you are using supported versions of Docker and docker-compose. The included Dockerfiles and docker-compose.yml file work with the following From 639a8dc11858f87ee571791bb751315de690541b Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 22:13:06 +0000 Subject: [PATCH 0029/1164] Update PHONY targets --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f86cbad5e3..54d2db1424 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: base +.PHONY: build dev test build: docker build -t redis-py-base docker/base From a10751d94bbf2dd9bfc2f8946f5a3934df41b346 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 13 Jul 2020 23:14:26 +0000 Subject: [PATCH 0030/1164] Improvements based on review feedback: 1. All make targets are now PHONY. 2. Cleaned up the 'dev' target so that it passes --build to docker-compose. 3. Added pypy-specific tox environments. And added a new Dockerfile to build an image for the "test" container that contains pypy and pypy3. 4. Added a `make clean` target. It removes containers but requires the user to confirm. 5. Specify the depends_on order for slave -> master and made all sentinels depend on the slave coming up. The container running doesn't mean that redis is actually ready though, so I wrapped the "test" target in the Makefile with a wait script that waits until master is responding on port 6379. --- Makefile | 14 ++-- docker-compose.yml | 12 ++- docker/test/Dockerfile | 3 + tox.ini | 12 +++ util/wait-for-it.sh | 183 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 docker/test/Dockerfile create mode 100755 util/wait-for-it.sh diff --git a/Makefile b/Makefile index 54d2db1424..d64d4403d0 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ .PHONY: build dev test -build: +base: docker build -t redis-py-base docker/base - docker-compose build -dev: - docker-compose up -d +dev: base + docker-compose up -d --build test: dev - find . -name "*.pyc" -exec rm -f {} \; - docker-compose run test tox -- --redis-url=redis://master:6379/9 + docker-compose run test util/wait-for-it.sh master:6379 -- tox -- --redis-url=redis://master:6379/9 + +clean: + docker-compose stop + docker-compose rm diff --git a/docker-compose.yml b/docker-compose.yml index c7a7c0a2b0..12d92b6e4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,26 +8,36 @@ services: slave: build: docker/slave + depends_on: + - "master" ports: - "6380:6380" sentinel_1: build: docker/sentinel_1 + depends_on: + - "slave" ports: - "26379:26379" sentinel_2: build: docker/sentinel_2 + depends_on: + - "slave" ports: - "26380:26380" sentinel_3: build: docker/sentinel_3 + depends_on: + - "slave" ports: - "26381:26381" test: - image: fkrull/multi-python:latest + build: docker/test working_dir: /redis-py + depends_on: + - "sentinel_3" volumes: - .:/redis-py diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile new file mode 100644 index 0000000000..768b081847 --- /dev/null +++ b/docker/test/Dockerfile @@ -0,0 +1,3 @@ +FROM fkrull/multi-python:latest + +RUN apt install -y pypy pypy3 diff --git a/tox.ini b/tox.ini index 9518954940..da92df6ebc 100644 --- a/tox.ini +++ b/tox.ini @@ -17,3 +17,15 @@ deps = flake8 commands = flake8 skipsdist = true skip_install = true + +[testenv:pypy-plain] +basepython = pypy + +[testenv:pypy-hiredis] +basepython = pypy + +[testenv:pypy3-plain] +basepython = pypy3 + +[testenv:pypy3-hiredis] +basepython = pypy3 diff --git a/util/wait-for-it.sh b/util/wait-for-it.sh new file mode 100755 index 0000000000..b931da1fb9 --- /dev/null +++ b/util/wait-for-it.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi + From 73b694dc02782844cf709e34933dbd1126b2a536 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 14 Jul 2020 02:39:44 +0000 Subject: [PATCH 0031/1164] Second try to add pypy and pypy3 to test runs --- docker/test/Dockerfile | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile index 768b081847..e57baf9c46 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -1,3 +1,3 @@ FROM fkrull/multi-python:latest -RUN apt install -y pypy pypy3 +RUN apt install -y pypy pypy-dev pypy3-dev diff --git a/tox.ini b/tox.ini index da92df6ebc..f9dbe276f7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 2.4 -envlist = {py27,py35,py36,py37,py38,py,py3}-{plain,hiredis}, flake8 +envlist = {py27,py35,py36,py37,py38,py,py3,pypy,pypy3}-{plain,hiredis}, flake8 [testenv] deps = From 59bb043253638c927c3f324523fc3fd529803aaf Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 14 Jul 2020 04:30:30 +0000 Subject: [PATCH 0032/1164] Clean up cache files; delete root-owned .tox files... --- .dockerignore | 3 +++ Makefile | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..08ea4a6294 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +**/__pycache__ +**/*.pyc +.tox diff --git a/Makefile b/Makefile index d64d4403d0..371e7089f0 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,10 @@ dev: base docker-compose up -d --build test: dev + docker-compose run test find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete docker-compose run test util/wait-for-it.sh master:6379 -- tox -- --redis-url=redis://master:6379/9 clean: docker-compose stop docker-compose rm + rm -rf .tox From 7dfcd3bfffc20dcee30cd047bb527112f28b1e5d Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 14 Jul 2020 04:30:53 +0000 Subject: [PATCH 0033/1164] Attempt to fix a timing bug --- tests/test_connection_pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 40ac3407db..e6e8e58a63 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -143,8 +143,8 @@ def target(): time.sleep(0.1) pool.release(c1) - Thread(target=target).start() start = time.time() + Thread(target=target).start() pool.get_connection('_') assert time.time() - start >= 0.1 From 8fb3f97c4ec502e337140d9c6e89c9661da6b54f Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 14 Jul 2020 04:33:15 +0000 Subject: [PATCH 0034/1164] Remove a rm command that would not work anyway --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 371e7089f0..d4bfd9d395 100644 --- a/Makefile +++ b/Makefile @@ -13,4 +13,3 @@ test: dev clean: docker-compose stop docker-compose rm - rm -rf .tox From b2ef59b6a8f292871842c0c168f22641e58c68aa Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 14 Jul 2020 04:37:16 +0000 Subject: [PATCH 0035/1164] Remove .tox from container side --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d4bfd9d395..c305f47768 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,8 @@ dev: base test: dev docker-compose run test find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete - docker-compose run test util/wait-for-it.sh master:6379 -- tox -- --redis-url=redis://master:6379/9 + docker-compose run test util/wait-for-it.sh master:6379 -- tox -e py3-plain -- --redis-url=redis://master:6379/9 + docker-compose run test rm -rf .tox clean: docker-compose stop From df50cf617bc256d63a942eacb17ebe865041334c Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 14 Jul 2020 04:40:35 +0000 Subject: [PATCH 0036/1164] Test all testenvs --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c305f47768..4d8a3d50cc 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ dev: base test: dev docker-compose run test find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete - docker-compose run test util/wait-for-it.sh master:6379 -- tox -e py3-plain -- --redis-url=redis://master:6379/9 + docker-compose run test util/wait-for-it.sh master:6379 -- tox -- --redis-url=redis://master:6379/9 docker-compose run test rm -rf .tox clean: From aef6d4b362dfacf2db3c23f06b2e1c09719e8836 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 14 Jul 2020 23:57:11 +0000 Subject: [PATCH 0037/1164] Move test Dockerfile into root, use COPY --- docker/test/Dockerfile => Dockerfile | 1 + Makefile | 4 +--- docker-compose.yml | 4 +--- tox.ini | 3 +++ 4 files changed, 6 insertions(+), 6 deletions(-) rename docker/test/Dockerfile => Dockerfile (81%) diff --git a/docker/test/Dockerfile b/Dockerfile similarity index 81% rename from docker/test/Dockerfile rename to Dockerfile index e57baf9c46..cd530ef2cd 100644 --- a/docker/test/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ FROM fkrull/multi-python:latest RUN apt install -y pypy pypy-dev pypy3-dev +COPY . /redis-py diff --git a/Makefile b/Makefile index 4d8a3d50cc..a3ebfa92fb 100644 --- a/Makefile +++ b/Makefile @@ -7,9 +7,7 @@ dev: base docker-compose up -d --build test: dev - docker-compose run test find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete - docker-compose run test util/wait-for-it.sh master:6379 -- tox -- --redis-url=redis://master:6379/9 - docker-compose run test rm -rf .tox + docker-compose run --rm test util/wait-for-it.sh master:6379 -- tox -- --redis-url=redis://master:6379/9 clean: docker-compose stop diff --git a/docker-compose.yml b/docker-compose.yml index 12d92b6e4a..631e2f8fb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,9 +35,7 @@ services: - "26381:26381" test: - build: docker/test + build: . working_dir: /redis-py depends_on: - "sentinel_3" - volumes: - - .:/redis-py diff --git a/tox.ini b/tox.ini index f9dbe276f7..b7d0a6a4c0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,3 +1,6 @@ +[pytest] +addopts = -s + [tox] minversion = 2.4 envlist = {py27,py35,py36,py37,py38,py,py3,pypy,pypy3}-{plain,hiredis}, flake8 From 55326cad46444899b58ceaddf98958f33e4fd7b3 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 19:46:33 +0000 Subject: [PATCH 0038/1164] Include wait-for-it.sh copyright & license --- util/wait-for-it.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/util/wait-for-it.sh b/util/wait-for-it.sh index b931da1fb9..ed711aec1b 100755 --- a/util/wait-for-it.sh +++ b/util/wait-for-it.sh @@ -1,5 +1,26 @@ #!/usr/bin/env bash # Use this script to test if a given TCP host/port are available +# +# The MIT License (MIT) +# Copyright (c) 2016 Giles Hall + +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. WAITFORIT_cmdname=${0##*/} From 273c8fee6c486b10e6e9823b0dd8645a363530d1 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 13:07:06 -0700 Subject: [PATCH 0039/1164] remove unnecessary tox environments --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b7d0a6a4c0..30682fb9aa 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ addopts = -s [tox] minversion = 2.4 -envlist = {py27,py35,py36,py37,py38,py,py3,pypy,pypy3}-{plain,hiredis}, flake8 +envlist = {py27,py35,py36,py37,py38,pypy,pypy3}-{plain,hiredis}, flake8 [testenv] deps = From a5508b141e15b752f97263325b5114fb0b48476a Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 13:08:43 -0700 Subject: [PATCH 0040/1164] force removal of docker images rather than ask the user if it's ok --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a3ebfa92fb..9cce0ffaea 100644 --- a/Makefile +++ b/Makefile @@ -11,4 +11,4 @@ test: dev clean: docker-compose stop - docker-compose rm + docker-compose rm -f From 0fe33ba87d3d2a2dc84750489fccc193ac79d298 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 13:16:40 -0700 Subject: [PATCH 0041/1164] update PHONY targets --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9cce0ffaea..d21d4f4526 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build dev test +.PHONY: base clean dev test base: docker build -t redis-py-base docker/base From 694f4054fa7a04a7dc5125a22ce237b8d3ef50fc Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 20:19:10 +0000 Subject: [PATCH 0042/1164] Clean up the wait-for-it.sh license --- util/wait-for-it.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/util/wait-for-it.sh b/util/wait-for-it.sh index ed711aec1b..c7a08d4381 100755 --- a/util/wait-for-it.sh +++ b/util/wait-for-it.sh @@ -1,19 +1,19 @@ #!/usr/bin/env bash # Use this script to test if a given TCP host/port are available # -# The MIT License (MIT) # Copyright (c) 2016 Giles Hall - +# The MIT License (MIT) +# # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in # the Software without restriction, including without limitation the rights to # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies # of the Software, and to permit persons to whom the Software is furnished to do # so, subject to the following conditions: - +# # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. - +# # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE From 462fd34a7844a49556f8db5c0af23d81a353c9ed Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 14:10:30 -0700 Subject: [PATCH 0043/1164] master/slave configs for docker --- docker/master/Dockerfile | 4 ++-- docker/master/redis.conf | 8 +------- docker/slave/Dockerfile | 4 ++-- docker/slave/redis.conf | 11 ++--------- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/docker/master/Dockerfile b/docker/master/Dockerfile index af81fb30c0..1f4bd7391a 100644 --- a/docker/master/Dockerfile +++ b/docker/master/Dockerfile @@ -1,7 +1,7 @@ FROM redis-py-base:latest -COPY redis.conf /etc/conf/redis/redis.conf +COPY redis.conf /usr/local/etc/redis/redis.conf EXPOSE 6379 -CMD redis-server +CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ] diff --git a/docker/master/redis.conf b/docker/master/redis.conf index cd1c984a93..271bfaacf2 100644 --- a/docker/master/redis.conf +++ b/docker/master/redis.conf @@ -1,9 +1,3 @@ -pidfile /var/run/redis-master.pid bind * port 6379 -daemonize yes -unixsocket /tmp/redis_master.sock -unixsocketperm 777 -dbfilename master.rdb -dir /var/lib/redis/backups -logfile /var/log/redis-master +save "" diff --git a/docker/slave/Dockerfile b/docker/slave/Dockerfile index 74c1fb0903..7b6bdbe1b9 100644 --- a/docker/slave/Dockerfile +++ b/docker/slave/Dockerfile @@ -1,7 +1,7 @@ FROM redis-py-base:latest -COPY redis.conf /etc/conf/redis/redis.conf +COPY redis.conf /usr/local/etc/redis/redis.conf EXPOSE 6380 -CMD redis-server +CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ] diff --git a/docker/slave/redis.conf b/docker/slave/redis.conf index b51cb8464c..e2d8aba5ef 100644 --- a/docker/slave/redis.conf +++ b/docker/slave/redis.conf @@ -1,11 +1,4 @@ -pidfile /var/run/redis-slave.pid bind * port 6380 -daemonize yes -unixsocket /tmp/redis-slave.sock -unixsocketperm 777 -dbfilename slave.rdb -dir /var/lib/redis/backups -logfile /var/log/redis-slave - -slaveof 127.0.0.1 6379 +save "" +slaveof master 6379 From 26b611e9732ae655c47f768fa5b8771cb69572f2 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 15:15:03 -0700 Subject: [PATCH 0044/1164] update configs to bind to explicit addresses. configure sentinel --- docker/master/redis.conf | 2 +- docker/sentinel_1/Dockerfile | 4 ++-- docker/sentinel_1/redis.conf | 10 +++++----- docker/sentinel_2/Dockerfile | 4 ++-- docker/sentinel_2/redis.conf | 10 +++++----- docker/sentinel_3/Dockerfile | 4 ++-- docker/sentinel_3/redis.conf | 10 +++++----- docker/slave/redis.conf | 2 +- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docker/master/redis.conf b/docker/master/redis.conf index 271bfaacf2..ed007666b6 100644 --- a/docker/master/redis.conf +++ b/docker/master/redis.conf @@ -1,3 +1,3 @@ -bind * +bind master 127.0.0.1 port 6379 save "" diff --git a/docker/sentinel_1/Dockerfile b/docker/sentinel_1/Dockerfile index e3c3e7b16d..3c3f8c2952 100644 --- a/docker/sentinel_1/Dockerfile +++ b/docker/sentinel_1/Dockerfile @@ -1,7 +1,7 @@ FROM redis-py-base:latest -COPY redis.conf /etc/conf/redis/redis.conf +COPY redis.conf /usr/local/etc/redis/redis.conf EXPOSE 26379 -CMD redis-server +CMD [ "redis-sentinel", "/usr/local/etc/redis/redis.conf" ] diff --git a/docker/sentinel_1/redis.conf b/docker/sentinel_1/redis.conf index d12979fc0f..fc9aa68c29 100644 --- a/docker/sentinel_1/redis.conf +++ b/docker/sentinel_1/redis.conf @@ -1,7 +1,7 @@ -pidfile /var/run/sentinel-1.pid +bind sentinel_1 127.0.0.1 port 26379 -daemonize yes -logfile /var/log/redis-sentinel-1 -# short timeout for sentinel tests -sentinel down-after-milliseconds mymaster 500 +sentinel monitor redis-py-test master 6379 2 +sentinel down-after-milliseconds redis-py-test 5000 +sentinel failover-timeout redis-py-test 60000 +sentinel parallel-syncs redis-py-test 1 diff --git a/docker/sentinel_2/Dockerfile b/docker/sentinel_2/Dockerfile index cd59777d3c..dc3c3323d9 100644 --- a/docker/sentinel_2/Dockerfile +++ b/docker/sentinel_2/Dockerfile @@ -1,7 +1,7 @@ FROM redis-py-base:latest -COPY redis.conf /etc/conf/redis/redis.conf +COPY redis.conf /usr/local/etc/redis/redis.conf EXPOSE 26380 -CMD redis-server +CMD [ "redis-sentinel", "/usr/local/etc/redis/redis.conf" ] diff --git a/docker/sentinel_2/redis.conf b/docker/sentinel_2/redis.conf index 9ab89348a4..264443cbd4 100644 --- a/docker/sentinel_2/redis.conf +++ b/docker/sentinel_2/redis.conf @@ -1,7 +1,7 @@ -pidfile /var/run/sentinel-2.pid +bind sentinel_2 127.0.0.1 port 26380 -daemonize yes -logfile /var/log/redis-sentinel-2 -# short timeout for sentinel tests -sentinel down-after-milliseconds mymaster 500 +sentinel monitor redis-py-test master 6379 2 +sentinel down-after-milliseconds redis-py-test 5000 +sentinel failover-timeout redis-py-test 60000 +sentinel parallel-syncs redis-py-test 1 diff --git a/docker/sentinel_3/Dockerfile b/docker/sentinel_3/Dockerfile index 2e81a15500..f0d48c9511 100644 --- a/docker/sentinel_3/Dockerfile +++ b/docker/sentinel_3/Dockerfile @@ -1,7 +1,7 @@ FROM redis-py-base:latest -COPY redis.conf /etc/conf/redis/redis.conf +COPY redis.conf /usr/local/etc/redis/redis.conf EXPOSE 26381 -CMD redis-server +CMD [ "redis-sentinel", "/usr/local/etc/redis/redis.conf" ] diff --git a/docker/sentinel_3/redis.conf b/docker/sentinel_3/redis.conf index b971b2ff45..b0827f1f45 100644 --- a/docker/sentinel_3/redis.conf +++ b/docker/sentinel_3/redis.conf @@ -1,7 +1,7 @@ -pidfile /var/run/sentinel-3.pid +bind sentinel_3 127.0.0.1 port 26381 -daemonize yes -logfile /var/log/redis-sentinel-3 -# short timeout for sentinel tests -sentinel down-after-milliseconds mymaster 500 +sentinel monitor redis-py-test master 6379 2 +sentinel down-after-milliseconds redis-py-test 5000 +sentinel failover-timeout redis-py-test 60000 +sentinel parallel-syncs redis-py-test 1 diff --git a/docker/slave/redis.conf b/docker/slave/redis.conf index e2d8aba5ef..629ac70c62 100644 --- a/docker/slave/redis.conf +++ b/docker/slave/redis.conf @@ -1,4 +1,4 @@ -bind * +bind slave 127.0.0.1 port 6380 save "" slaveof master 6379 From 8c7a8160c7d3a7f4325c77aba4a563004f77cf27 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 22:24:46 +0000 Subject: [PATCH 0045/1164] Check that we're subscribed to the right channels --- tests/test_pubsub.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 31b60be373..bf134831c4 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -475,11 +475,8 @@ def test_pubsub_channels(self, r): p.subscribe('foo', 'bar', 'baz', 'quux') for i in range(4): assert wait_for_message(p)['type'] == 'subscribe' - channels = sorted(r.pubsub_channels()) - # assert channels == [b'bar', b'baz', b'foo', b'quux'] - if channels != [b'bar', b'baz', b'foo', b'quux']: - import pdb - pdb.set_trace() + expected = [b'bar', b'baz', b'foo', b'quux'] + assert all([channel in r.pubsub_channels() for channel in expected]) @skip_if_server_version_lt('2.8.0') def test_pubsub_numsub(self, r): From 54a8656fd40f45f008eb5fe12b0c31892fb6f654 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 15:24:45 -0700 Subject: [PATCH 0046/1164] rename sentinel configs to sentinel.conf for clarity --- docker/sentinel_1/Dockerfile | 4 ++-- docker/sentinel_1/{redis.conf => sentinel.conf} | 0 docker/sentinel_2/Dockerfile | 4 ++-- docker/sentinel_2/{redis.conf => sentinel.conf} | 0 docker/sentinel_3/Dockerfile | 4 ++-- docker/sentinel_3/{redis.conf => sentinel.conf} | 0 6 files changed, 6 insertions(+), 6 deletions(-) rename docker/sentinel_1/{redis.conf => sentinel.conf} (100%) rename docker/sentinel_2/{redis.conf => sentinel.conf} (100%) rename docker/sentinel_3/{redis.conf => sentinel.conf} (100%) diff --git a/docker/sentinel_1/Dockerfile b/docker/sentinel_1/Dockerfile index 3c3f8c2952..66f6a75e5b 100644 --- a/docker/sentinel_1/Dockerfile +++ b/docker/sentinel_1/Dockerfile @@ -1,7 +1,7 @@ FROM redis-py-base:latest -COPY redis.conf /usr/local/etc/redis/redis.conf +COPY sentinel.conf /usr/local/etc/redis/sentinel.conf EXPOSE 26379 -CMD [ "redis-sentinel", "/usr/local/etc/redis/redis.conf" ] +CMD [ "redis-sentinel", "/usr/local/etc/redis/sentinel.conf" ] diff --git a/docker/sentinel_1/redis.conf b/docker/sentinel_1/sentinel.conf similarity index 100% rename from docker/sentinel_1/redis.conf rename to docker/sentinel_1/sentinel.conf diff --git a/docker/sentinel_2/Dockerfile b/docker/sentinel_2/Dockerfile index dc3c3323d9..1c0bb92e23 100644 --- a/docker/sentinel_2/Dockerfile +++ b/docker/sentinel_2/Dockerfile @@ -1,7 +1,7 @@ FROM redis-py-base:latest -COPY redis.conf /usr/local/etc/redis/redis.conf +COPY sentinel.conf /usr/local/etc/redis/sentinel.conf EXPOSE 26380 -CMD [ "redis-sentinel", "/usr/local/etc/redis/redis.conf" ] +CMD [ "redis-sentinel", "/usr/local/etc/redis/sentinel.conf" ] diff --git a/docker/sentinel_2/redis.conf b/docker/sentinel_2/sentinel.conf similarity index 100% rename from docker/sentinel_2/redis.conf rename to docker/sentinel_2/sentinel.conf diff --git a/docker/sentinel_3/Dockerfile b/docker/sentinel_3/Dockerfile index f0d48c9511..cf1ec21b70 100644 --- a/docker/sentinel_3/Dockerfile +++ b/docker/sentinel_3/Dockerfile @@ -1,7 +1,7 @@ FROM redis-py-base:latest -COPY redis.conf /usr/local/etc/redis/redis.conf +COPY sentinel.conf /usr/local/etc/redis/sentinel.conf EXPOSE 26381 -CMD [ "redis-sentinel", "/usr/local/etc/redis/redis.conf" ] +CMD [ "redis-sentinel", "/usr/local/etc/redis/sentinel.conf" ] diff --git a/docker/sentinel_3/redis.conf b/docker/sentinel_3/sentinel.conf similarity index 100% rename from docker/sentinel_3/redis.conf rename to docker/sentinel_3/sentinel.conf From 1f5b2a6cd31ad4298bf6be174796b8aa3d3913db Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 15:37:13 -0700 Subject: [PATCH 0047/1164] no longer need to `make build`. `make test` will do everything --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 0e3b1f9496..4b09cfcced 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,5 +11,4 @@ services: - docker script: - - make build - make test From c8dc6a6b0908cf01232dd3491168dfe9ce8d7732 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 15:37:39 -0700 Subject: [PATCH 0048/1164] rename absurdly long test name --- tests/test_connection_pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index e6e8e58a63..c49ecda8b6 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -129,7 +129,7 @@ def test_connection_pool_blocks_until_timeout(self, master_host): # we should have waited at least 0.1 seconds assert time.time() - start >= 0.1 - def test_connection_pool_blocks_until_another_connection_released(self, master_host): # noqa: E501 + def test_connection_pool_blocks_until_conn_available(self, master_host): """ When out of connections, block until another connection is released to the pool From 07abda77af8938fb119e02ee2f94524a96f68947 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 22:47:50 +0000 Subject: [PATCH 0049/1164] Remove Vagrant files --- build_tools/.bash_profile | 1 - build_tools/bootstrap.sh | 4 --- build_tools/build_redis.sh | 29 ---------------- build_tools/install_redis.sh | 37 --------------------- build_tools/install_sentinel.sh | 37 --------------------- build_tools/redis-configs/001-master | 8 ----- build_tools/redis-configs/002-slave | 10 ------ build_tools/redis_init_script | 49 ---------------------------- build_tools/redis_vars.sh | 48 --------------------------- build_tools/sentinel-configs/001-1 | 6 ---- build_tools/sentinel-configs/002-2 | 6 ---- build_tools/sentinel-configs/003-3 | 6 ---- build_tools/sentinel_init_script | 49 ---------------------------- vagrant/Vagrantfile | 27 --------------- 14 files changed, 317 deletions(-) delete mode 100644 build_tools/.bash_profile delete mode 100755 build_tools/bootstrap.sh delete mode 100755 build_tools/build_redis.sh delete mode 100755 build_tools/install_redis.sh delete mode 100755 build_tools/install_sentinel.sh delete mode 100644 build_tools/redis-configs/001-master delete mode 100644 build_tools/redis-configs/002-slave delete mode 100755 build_tools/redis_init_script delete mode 100755 build_tools/redis_vars.sh delete mode 100644 build_tools/sentinel-configs/001-1 delete mode 100644 build_tools/sentinel-configs/002-2 delete mode 100644 build_tools/sentinel-configs/003-3 delete mode 100755 build_tools/sentinel_init_script delete mode 100644 vagrant/Vagrantfile diff --git a/build_tools/.bash_profile b/build_tools/.bash_profile deleted file mode 100644 index b023cf70a7..0000000000 --- a/build_tools/.bash_profile +++ /dev/null @@ -1 +0,0 @@ -PATH=$PATH:/var/lib/redis/bin diff --git a/build_tools/bootstrap.sh b/build_tools/bootstrap.sh deleted file mode 100755 index a5a0d2ce83..0000000000 --- a/build_tools/bootstrap.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash - -# need make to build redis -sudo apt-get install make diff --git a/build_tools/build_redis.sh b/build_tools/build_redis.sh deleted file mode 100755 index 379c6cc936..0000000000 --- a/build_tools/build_redis.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -source /home/vagrant/redis-py/build_tools/redis_vars.sh - -pushd /home/vagrant - -uninstall_all_sentinel_instances -uninstall_all_redis_instances - -# create a clean directory for redis -rm -rf $REDIS_DIR -mkdir -p $REDIS_BIN_DIR -mkdir -p $REDIS_CONF_DIR -mkdir -p $REDIS_SAVE_DIR - -# download, unpack and build redis -mkdir -p $REDIS_DOWNLOAD_DIR -cd $REDIS_DOWNLOAD_DIR -rm -f $REDIS_PACKAGE -rm -rf $REDIS_BUILD_DIR -wget http://download.redis.io/releases/$REDIS_PACKAGE -tar zxvf $REDIS_PACKAGE -cd $REDIS_BUILD_DIR -make -cp src/redis-server $REDIS_DIR/bin -cp src/redis-cli $REDIS_DIR/bin -cp src/redis-sentinel $REDIS_DIR/bin - -popd diff --git a/build_tools/install_redis.sh b/build_tools/install_redis.sh deleted file mode 100755 index fd53a1ca88..0000000000 --- a/build_tools/install_redis.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash - -source /home/vagrant/redis-py/build_tools/redis_vars.sh - -for filename in `ls $VAGRANT_REDIS_CONF_DIR`; do - # cuts the order prefix off of the filename, e.g. 001-master -> master - PROCESS_NAME=redis-`echo $filename | cut -f 2- -d -` - echo "======================================" - echo "INSTALLING REDIS SERVER: $PROCESS_NAME" - echo "======================================" - - # make sure the instance is uninstalled (it should be already) - uninstall_instance $PROCESS_NAME - - # base config - mkdir -p $REDIS_CONF_DIR - cp $REDIS_BUILD_DIR/redis.conf $REDIS_CONF_DIR/$PROCESS_NAME.conf - # override config values from file - cat $VAGRANT_REDIS_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf - - # replace placeholder variables in init.d script - cp $VAGRANT_DIR/redis_init_script /etc/init.d/$PROCESS_NAME - sed -i "s/{{ PROCESS_NAME }}/$PROCESS_NAME/g" /etc/init.d/$PROCESS_NAME - # need to read the config file to find out what port this instance will run on - port=`grep port $VAGRANT_REDIS_CONF_DIR/$filename | cut -f 2 -d " "` - sed -i "s/{{ PORT }}/$port/g" /etc/init.d/$PROCESS_NAME - chmod 755 /etc/init.d/$PROCESS_NAME - - # and tell update-rc.d about it - update-rc.d $PROCESS_NAME defaults 98 - - # save the $PROCESS_NAME into installed instances file - echo $PROCESS_NAME >> $REDIS_INSTALLED_INSTANCES_FILE - - # start redis - /etc/init.d/$PROCESS_NAME start -done diff --git a/build_tools/install_sentinel.sh b/build_tools/install_sentinel.sh deleted file mode 100755 index 0597208ccf..0000000000 --- a/build_tools/install_sentinel.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash - -source /home/vagrant/redis-py/build_tools/redis_vars.sh - -for filename in `ls $VAGRANT_SENTINEL_CONF_DIR`; do - # cuts the order prefix off of the filename, e.g. 001-master -> master - PROCESS_NAME=sentinel-`echo $filename | cut -f 2- -d -` - echo "=========================================" - echo "INSTALLING SENTINEL SERVER: $PROCESS_NAME" - echo "=========================================" - - # make sure the instance is uninstalled (it should be already) - uninstall_instance $PROCESS_NAME - - # base config - mkdir -p $REDIS_CONF_DIR - cp $REDIS_BUILD_DIR/sentinel.conf $REDIS_CONF_DIR/$PROCESS_NAME.conf - # override config values from file - cat $VAGRANT_SENTINEL_CONF_DIR/$filename >> $REDIS_CONF_DIR/$PROCESS_NAME.conf - - # replace placeholder variables in init.d script - cp $VAGRANT_DIR/sentinel_init_script /etc/init.d/$PROCESS_NAME - sed -i "s/{{ PROCESS_NAME }}/$PROCESS_NAME/g" /etc/init.d/$PROCESS_NAME - # need to read the config file to find out what port this instance will run on - port=`grep port $VAGRANT_SENTINEL_CONF_DIR/$filename | cut -f 2 -d " "` - sed -i "s/{{ PORT }}/$port/g" /etc/init.d/$PROCESS_NAME - chmod 755 /etc/init.d/$PROCESS_NAME - - # and tell update-rc.d about it - update-rc.d $PROCESS_NAME defaults 99 - - # save the $PROCESS_NAME into installed instances file - echo $PROCESS_NAME >> $SENTINEL_INSTALLED_INSTANCES_FILE - - # start redis - /etc/init.d/$PROCESS_NAME start -done diff --git a/build_tools/redis-configs/001-master b/build_tools/redis-configs/001-master deleted file mode 100644 index 8591f1a61e..0000000000 --- a/build_tools/redis-configs/001-master +++ /dev/null @@ -1,8 +0,0 @@ -pidfile /var/run/redis-master.pid -bind * -port 6379 -daemonize yes -unixsocket /tmp/redis_master.sock -unixsocketperm 777 -dbfilename master.rdb -dir /var/lib/redis/backups diff --git a/build_tools/redis-configs/002-slave b/build_tools/redis-configs/002-slave deleted file mode 100644 index 13eb77ec4d..0000000000 --- a/build_tools/redis-configs/002-slave +++ /dev/null @@ -1,10 +0,0 @@ -pidfile /var/run/redis-slave.pid -bind * -port 6380 -daemonize yes -unixsocket /tmp/redis-slave.sock -unixsocketperm 777 -dbfilename slave.rdb -dir /var/lib/redis/backups - -slaveof 127.0.0.1 6379 diff --git a/build_tools/redis_init_script b/build_tools/redis_init_script deleted file mode 100755 index 04cb2dbc7c..0000000000 --- a/build_tools/redis_init_script +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/sh - -### BEGIN INIT INFO -# Provides: redis-server -# Required-Start: $syslog -# Required-Stop: $syslog -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 -# Short-Description: Start redis-server at boot time -# Description: Control redis-server. -### END INIT INFO - -REDISPORT={{ PORT }} -PIDFILE=/var/run/{{ PROCESS_NAME }}.pid -CONF=/var/lib/redis/conf/{{ PROCESS_NAME }}.conf - -EXEC=/var/lib/redis/bin/redis-server -CLIEXEC=/var/lib/redis/bin/redis-cli - -case "$1" in - start) - if [ -f $PIDFILE ] - then - echo "$PIDFILE exists, process is already running or crashed" - else - echo "Starting Redis server..." - $EXEC $CONF - fi - ;; - stop) - if [ ! -f $PIDFILE ] - then - echo "$PIDFILE does not exist, process is not running" - else - PID=$(cat $PIDFILE) - echo "Stopping ..." - $CLIEXEC -p $REDISPORT shutdown - while [ -x /proc/${PID} ] - do - echo "Waiting for Redis to shutdown ..." - sleep 1 - done - echo "Redis stopped" - fi - ;; - *) - echo "Please use start or stop as first argument" - ;; -esac diff --git a/build_tools/redis_vars.sh b/build_tools/redis_vars.sh deleted file mode 100755 index c52dd4cf37..0000000000 --- a/build_tools/redis_vars.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash - -VAGRANT_DIR=/home/vagrant/redis-py/build_tools -VAGRANT_REDIS_CONF_DIR=$VAGRANT_DIR/redis-configs -VAGRANT_SENTINEL_CONF_DIR=$VAGRANT_DIR/sentinel-configs -REDIS_VERSION=3.2.0 -REDIS_DOWNLOAD_DIR=/home/vagrant/redis-downloads -REDIS_PACKAGE=redis-$REDIS_VERSION.tar.gz -REDIS_BUILD_DIR=$REDIS_DOWNLOAD_DIR/redis-$REDIS_VERSION -REDIS_DIR=/var/lib/redis -REDIS_BIN_DIR=$REDIS_DIR/bin -REDIS_CONF_DIR=$REDIS_DIR/conf -REDIS_SAVE_DIR=$REDIS_DIR/backups -REDIS_INSTALLED_INSTANCES_FILE=$REDIS_DIR/redis-instances -SENTINEL_INSTALLED_INSTANCES_FILE=$REDIS_DIR/sentinel-instances - -function uninstall_instance() { - # Expects $1 to be the init.d filename, e.g. redis-nodename or - # sentinel-nodename - - if [ -a /etc/init.d/$1 ]; then - - echo "======================================" - echo "UNINSTALLING REDIS SERVER: $1" - echo "======================================" - - /etc/init.d/$1 stop - update-rc.d -f $1 remove - rm -f /etc/init.d/$1 - fi; - rm -f $REDIS_CONF_DIR/$1.conf -} - -function uninstall_all_redis_instances() { - if [ -a $REDIS_INSTALLED_INSTANCES_FILE ]; then - cat $REDIS_INSTALLED_INSTANCES_FILE | while read line; do - uninstall_instance $line; - done; - fi -} - -function uninstall_all_sentinel_instances() { - if [ -a $SENTINEL_INSTALLED_INSTANCES_FILE ]; then - cat $SENTINEL_INSTALLED_INSTANCES_FILE | while read line; do - uninstall_instance $line; - done; - fi -} diff --git a/build_tools/sentinel-configs/001-1 b/build_tools/sentinel-configs/001-1 deleted file mode 100644 index eccc3d1f84..0000000000 --- a/build_tools/sentinel-configs/001-1 +++ /dev/null @@ -1,6 +0,0 @@ -pidfile /var/run/sentinel-1.pid -port 26379 -daemonize yes - -# short timeout for sentinel tests -sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/sentinel-configs/002-2 b/build_tools/sentinel-configs/002-2 deleted file mode 100644 index 0cd28019c4..0000000000 --- a/build_tools/sentinel-configs/002-2 +++ /dev/null @@ -1,6 +0,0 @@ -pidfile /var/run/sentinel-2.pid -port 26380 -daemonize yes - -# short timeout for sentinel tests -sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/sentinel-configs/003-3 b/build_tools/sentinel-configs/003-3 deleted file mode 100644 index c7f4fcd335..0000000000 --- a/build_tools/sentinel-configs/003-3 +++ /dev/null @@ -1,6 +0,0 @@ -pidfile /var/run/sentinel-3.pid -port 26381 -daemonize yes - -# short timeout for sentinel tests -sentinel down-after-milliseconds mymaster 500 diff --git a/build_tools/sentinel_init_script b/build_tools/sentinel_init_script deleted file mode 100755 index 1d94804e9c..0000000000 --- a/build_tools/sentinel_init_script +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/sh - -### BEGIN INIT INFO -# Provides: redis-sentintel -# Required-Start: $syslog -# Required-Stop: $syslog -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 -# Short-Description: Start redis-sentinel at boot time -# Description: Control redis-sentinel. -### END INIT INFO - -SENTINELPORT={{ PORT }} -PIDFILE=/var/run/{{ PROCESS_NAME }}.pid -CONF=/var/lib/redis/conf/{{ PROCESS_NAME }}.conf - -EXEC=/var/lib/redis/bin/redis-sentinel -CLIEXEC=/var/lib/redis/bin/redis-cli - -case "$1" in - start) - if [ -f $PIDFILE ] - then - echo "$PIDFILE exists, process is already running or crashed" - else - echo "Starting Redis Sentinel..." - $EXEC $CONF - fi - ;; - stop) - if [ ! -f $PIDFILE ] - then - echo "$PIDFILE does not exist, process is not running" - else - PID=$(cat $PIDFILE) - echo "Stopping ..." - $CLIEXEC -p $SENTINELPORT shutdown - while [ -x /proc/${PID} ] - do - echo "Waiting for Sentinel to shutdown ..." - sleep 1 - done - echo "Sentinel stopped" - fi - ;; - *) - echo "Please use start or stop as first argument" - ;; -esac diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile deleted file mode 100644 index 3ee7aee364..0000000000 --- a/vagrant/Vagrantfile +++ /dev/null @@ -1,27 +0,0 @@ -# -*- mode: ruby -*- -# vi: set ft=ruby : - -# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! -VAGRANTFILE_API_VERSION = "2" - -Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| - # ubuntu 64bit image - config.vm.box = "hashicorp/precise64" - - # map the root of redis-py to /home/vagrant/redis-py - config.vm.synced_folder "../", "/home/vagrant/redis-py" - - # install the redis server - config.vm.provision :shell, :path => "../build_tools/bootstrap.sh" - config.vm.provision :shell, :path => "../build_tools/build_redis.sh" - config.vm.provision :shell, :path => "../build_tools/install_redis.sh" - config.vm.provision :shell, :path => "../build_tools/install_sentinel.sh" - config.vm.provision :file, :source => "../build_tools/.bash_profile", :destination => "/home/vagrant/.bash_profile" - - # setup forwarded ports - config.vm.network "forwarded_port", guest: 6379, host: 6379 - config.vm.network "forwarded_port", guest: 6380, host: 6380 - config.vm.network "forwarded_port", guest: 26379, host: 26379 - config.vm.network "forwarded_port", guest: 26380, host: 26380 - config.vm.network "forwarded_port", guest: 26381, host: 26381 -end From 6feed6e2da6315bf016e41c1075fe46986b46dc1 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 23:02:57 +0000 Subject: [PATCH 0050/1164] Flesh out the docker env docs --- CONTRIBUTING.rst | 48 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c6990f224c..cecc418e7c 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -28,15 +28,53 @@ Getting Started Here's how to get started with your code contribution: 1. Create your own fork of redis-py -2. When you've checked out the fork locally, build the docker containers: ``make build`` -3. Do the changes in your fork -4. Make sure the tests pass by running: ``make test`` +2. Do the changes in your fork +3. If you need a development environment, run ``make dev`` +4. While developing, make sure the tests pass: ``make test`` 5. If you like the change and think the project could use it, send a pull request +The Development Environment +--------------------------- + +Running `make dev` will create a Docker-based development environment that starts the following containers: + +* A master node +* A slave node +* Three sentinel nodes +* A test container + +The slave is a replica of the master node, while the sentinels monitor it. + +Docker Tips +^^^^^^^^^^^ + +There are a few tips that can help you work with this environment: + +To get a bash shell inside of a container: + +``docker-compose run sentinel_1 /bin/bash`` + +Containers run a minimal Debian image that probably lacks tools you want to use. To install packages, first get a bash session (see previous tip) and then run: + +``apt update && apt install `` + +You can see the combined logging output of all containers like this: + +``docker-compose logs`` + +The command `make test` runs all tests in all tested Python environments. To run the tests in a single environment, like Python 3.6, use a command like this: + +``docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9`` + +Here, the flag ``-e py36`` runs tests against the Python 3.6 tox environment. And note from the example that whenever you run tests like this, instead of using `make test`, you need to pass `-- --redis-url=redis://master:6379/9``. This points the tests at the "master" container. + +Our test suite uses ``pytest``. You can run a specific test suite against a specific Python version like this: + +``docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9 tests/test_commands.py`` + Troubleshooting ^^^^^^^^^^^^^^^ - -If you get any errors when running ``make build`` or ``make test``, make sure that you +If you get any errors when running ``make dev`` or ``make test``, make sure that you are using supported versions of Docker and docker-compose. The included Dockerfiles and docker-compose.yml file work with the following From adebdc39302d81e58c7c9a39d667139db9166f1f Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 23:10:51 +0000 Subject: [PATCH 0051/1164] Editing the contrib guide --- CONTRIBUTING.rst | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index cecc418e7c..dbc1ffa9b4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -11,7 +11,7 @@ Contributions We Need You may already know what you want to contribute -- a fix for a bug you encountered, or a new feature your team wants to use. -If you don't know what to contribute, keep an open mind! Improving documentation, bug triaging, or writing tutorials are all examples of helpful contributions that mean less work for you. +If you don't know what to contribute, keep an open mind! Improving documentation, bug triaging, and writing tutorials are all examples of helpful contributions that mean less work for you. Your First Contribution ----------------------- @@ -30,20 +30,24 @@ Here's how to get started with your code contribution: 1. Create your own fork of redis-py 2. Do the changes in your fork 3. If you need a development environment, run ``make dev`` -4. While developing, make sure the tests pass: ``make test`` +4. While developing, make sure the tests pass by running ``make test`` 5. If you like the change and think the project could use it, send a pull request The Development Environment --------------------------- -Running `make dev` will create a Docker-based development environment that starts the following containers: +Running ``make dev`` will create a Docker-based development environment that starts the following containers: -* A master node -* A slave node -* Three sentinel nodes +* A master Redis node +* A slave Redis node +* Three sentinel Redis nodes * A test container -The slave is a replica of the master node, while the sentinels monitor it. +The slave is a replica of the master node, using the `leader-follower replication `_ feature. + +The sentinels monitor the master node in a `sentinel high-availability configuration `_. + +Meanwhile, the `test` container hosts the code from your checkout of `redis-py` and allows running tests against many Python versions. Docker Tips ^^^^^^^^^^^ From 61af925aa41a34f64a5a7bf6d330a99062661aa6 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 23:12:58 +0000 Subject: [PATCH 0052/1164] Editing --- CONTRIBUTING.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index dbc1ffa9b4..bfb4a27bd4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -15,7 +15,7 @@ If you don't know what to contribute, keep an open mind! Improving documentation Your First Contribution ----------------------- -Unsure where to begin contributing? You can start by looking through help-wanted issues: https://github.com/andymccurdy/redis-py/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted +Unsure where to begin contributing? You can start by looking through `help-wanted issues `_. Never contributed to open source before? Here are a couple of friendly tutorials: @@ -47,7 +47,7 @@ The slave is a replica of the master node, using the `leader-follower replicatio The sentinels monitor the master node in a `sentinel high-availability configuration `_. -Meanwhile, the `test` container hosts the code from your checkout of `redis-py` and allows running tests against many Python versions. +Meanwhile, the `test` container hosts the code from your checkout of ``redis-py`` and allows running tests against many Python versions. Docker Tips ^^^^^^^^^^^ From 344f589a52110b9d24da42b8c9f3590fde2b58a0 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 23:13:52 +0000 Subject: [PATCH 0053/1164] Editing --- CONTRIBUTING.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index bfb4a27bd4..e2db60f2ef 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -52,7 +52,7 @@ Meanwhile, the `test` container hosts the code from your checkout of ``redis-py` Docker Tips ^^^^^^^^^^^ -There are a few tips that can help you work with this environment: +Following are a few tips that can help you work with the Docker-based development environment. To get a bash shell inside of a container: From f67ab19ba9f45d63f7afc12e1aa9a994c4bff3da Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 23:19:00 +0000 Subject: [PATCH 0054/1164] Editing --- CONTRIBUTING.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e2db60f2ef..6dc745b329 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -56,7 +56,9 @@ Following are a few tips that can help you work with the Docker-based developmen To get a bash shell inside of a container: -``docker-compose run sentinel_1 /bin/bash`` +``docker-compose run /bin/bash`` + +**Note**: The term "service" refers to the "services" defined in the `docker-compose.yml` file: "master", "slave", "sentinel_1", "sentinel_2", "sentinel_3", "tests". Containers run a minimal Debian image that probably lacks tools you want to use. To install packages, first get a bash session (see previous tip) and then run: From 5c32b2320efd1e4bbae5a1bbf141426ce68b91fd Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 23:21:15 +0000 Subject: [PATCH 0055/1164] Editing --- CONTRIBUTING.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 6dc745b329..c54fc10748 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -56,27 +56,27 @@ Following are a few tips that can help you work with the Docker-based developmen To get a bash shell inside of a container: -``docker-compose run /bin/bash`` +``$ docker-compose run /bin/bash`` -**Note**: The term "service" refers to the "services" defined in the `docker-compose.yml` file: "master", "slave", "sentinel_1", "sentinel_2", "sentinel_3", "tests". +**Note**: The term "service" refers to the "services" defined in the ``docker-compose.yml`` file: "master", "slave", "sentinel_1", "sentinel_2", "sentinel_3", "test". Containers run a minimal Debian image that probably lacks tools you want to use. To install packages, first get a bash session (see previous tip) and then run: -``apt update && apt install `` +``$ apt update && apt install `` You can see the combined logging output of all containers like this: -``docker-compose logs`` +``$ docker-compose logs`` The command `make test` runs all tests in all tested Python environments. To run the tests in a single environment, like Python 3.6, use a command like this: -``docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9`` +``$ docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9`` Here, the flag ``-e py36`` runs tests against the Python 3.6 tox environment. And note from the example that whenever you run tests like this, instead of using `make test`, you need to pass `-- --redis-url=redis://master:6379/9``. This points the tests at the "master" container. Our test suite uses ``pytest``. You can run a specific test suite against a specific Python version like this: -``docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9 tests/test_commands.py`` +``$ docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9 tests/test_commands.py`` Troubleshooting ^^^^^^^^^^^^^^^ From 11ef996e664446e11c70fb5fff815c7acb25b9cb Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 15 Jul 2020 23:23:54 +0000 Subject: [PATCH 0056/1164] Editing docs --- CONTRIBUTING.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c54fc10748..edaa7b6773 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -72,7 +72,7 @@ The command `make test` runs all tests in all tested Python environments. To run ``$ docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9`` -Here, the flag ``-e py36`` runs tests against the Python 3.6 tox environment. And note from the example that whenever you run tests like this, instead of using `make test`, you need to pass `-- --redis-url=redis://master:6379/9``. This points the tests at the "master" container. +Here, the flag ``-e py36`` runs tests against the Python 3.6 tox environment. And note from the example that whenever you run tests like this, instead of using `make test`, you need to pass ``-- --redis-url=redis://master:6379/9``. This points the tests at the "master" container. Our test suite uses ``pytest``. You can run a specific test suite against a specific Python version like this: From 4cf1d83460eb25cc4dfdee090b212ff42456cd1c Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 15 Jul 2020 16:35:54 -0700 Subject: [PATCH 0057/1164] restore codecov --- .travis.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.travis.yml b/.travis.yml index 4b09cfcced..d31fea063d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,6 @@ +language: python +cache: pip + env: - DOCKER_COMPOSE_VERSION=1.26.2 @@ -10,5 +13,11 @@ before_install: services: - docker +install: + - pip install codecov + script: - make test + +after_success: + - codecov From 5ab3a8d158db34e3267e9b102b3b98949355b320 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 16 Jul 2020 00:46:19 +0000 Subject: [PATCH 0058/1164] Try running codecov in the test container --- .travis.yml | 8 +------- Dockerfile | 5 ++++- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index d31fea063d..8e90fab1d8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,3 @@ -language: python -cache: pip - env: - DOCKER_COMPOSE_VERSION=1.26.2 @@ -13,11 +10,8 @@ before_install: services: - docker -install: - - pip install codecov - script: - make test after_success: - - codecov + - docker-compose run test codecov diff --git a/Dockerfile b/Dockerfile index cd530ef2cd..185a477b72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ FROM fkrull/multi-python:latest -RUN apt install -y pypy pypy-dev pypy3-dev +RUN apt update && apt install -y pypy pypy-dev pypy3-dev curl +RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && python3.8 get-pip.py +RUN pip install codecov + COPY . /redis-py From 841d5cead05c18a9d2ad212ed2bee46670c5dff8 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 16 Jul 2020 00:48:34 +0000 Subject: [PATCH 0059/1164] Try to pass the codecov token... --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8e90fab1d8..95940af002 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,4 +14,4 @@ script: - make test after_success: - - docker-compose run test codecov + - docker-compose run -e CODECOV_TOKEN=$CODECOV_TOKEN test codecov From 8c7d2da53714c6c41364790ebbd68c10dbe38d83 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 14:40:29 -0700 Subject: [PATCH 0060/1164] testing docker-entry --- .travis.yml | 3 --- Dockerfile | 2 ++ Makefile | 14 +++++++------- docker-compose.yml | 4 +++- docker-entry.sh | 17 +++++++++++++++++ 5 files changed, 29 insertions(+), 11 deletions(-) create mode 100755 docker-entry.sh diff --git a/.travis.yml b/.travis.yml index 95940af002..4b09cfcced 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,3 @@ services: script: - make test - -after_success: - - docker-compose run -e CODECOV_TOKEN=$CODECOV_TOKEN test codecov diff --git a/Dockerfile b/Dockerfile index 185a477b72..3bcc3ec40c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,4 +4,6 @@ RUN apt update && apt install -y pypy pypy-dev pypy3-dev curl RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && python3.8 get-pip.py RUN pip install codecov +WORKDIR /redis-py + COPY . /redis-py diff --git a/Makefile b/Makefile index d21d4f4526..7c331355af 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ .PHONY: base clean dev test base: - docker build -t redis-py-base docker/base + docker build -t redis-py-base docker/base -dev: base - docker-compose up -d --build +dev: base + docker-compose up -d --build -test: dev - docker-compose run --rm test util/wait-for-it.sh master:6379 -- tox -- --redis-url=redis://master:6379/9 +test: dev + docker-compose run --rm -e TRAVIS test /redis-py/docker-entry.sh clean: - docker-compose stop - docker-compose rm -f + docker-compose stop + docker-compose rm -f diff --git a/docker-compose.yml b/docker-compose.yml index 631e2f8fb3..3adf9c2404 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: test: build: . - working_dir: /redis-py depends_on: - "sentinel_3" + environment: + REDIS_MASTER_HOST: master + REDIS_MASTER_PORT: "6379" diff --git a/docker-entry.sh b/docker-entry.sh new file mode 100755 index 0000000000..d1f232c48e --- /dev/null +++ b/docker-entry.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# This is the entrypoint for "make clean". It invokes Tox. If running +# within the CI environment, it also runs codecov + +set -eu + +REDIS_MASTER="${REDIS_MASTER_HOST}":"${REDIS_MASTER_PORT}" +echo "Testing against Redis Server: ${REDIS_MASTER}" + +# use the wait-for-it util to ensure the server is running before invoking Tox +util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTER}"/9 + +# if the TRAVIS env var is defined, invoke "codecov" +if [ ! -z ${TRAVIS-} ]; then + codecov +fi From 59b460a0f2d3a70739cf67a7c42f7cc59114a8dd Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 15:14:25 -0700 Subject: [PATCH 0061/1164] pass travis/codecov env vars to docker --- Makefile | 2 +- docker-compose.yml | 27 +++++++++++++++++++++++++-- docker-entry.sh | 2 +- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 7c331355af..0d0964345a 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ dev: base docker-compose up -d --build test: dev - docker-compose run --rm -e TRAVIS test /redis-py/docker-entry.sh + docker-compose run --rm test /redis-py/docker-entry.sh clean: docker-compose stop diff --git a/docker-compose.yml b/docker-compose.yml index 3adf9c2404..b76bf8357c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,5 +39,28 @@ services: depends_on: - "sentinel_3" environment: - REDIS_MASTER_HOST: master - REDIS_MASTER_PORT: "6379" + - REDIS_MASTER_HOST=master + - REDIS_MASTER_PORT=6379 + - CI_BUILD_ID + - CI_BUILD_URL + - CI_JOB_ID + - CODECOV_ENV + - CODECOV_SLUG + - CODECOV_TOKEN + - CODECOV_URL + - SHIPPABLE + - TRAVIS + - TRAVIS_BRANCH + - TRAVIS_COMMIT + - TRAVIS_JOB_ID + - TRAVIS_JOB_NUMBER + - TRAVIS_OS_NAME + - TRAVIS_PULL_REQUEST + - TRAVIS_REPO_SLUG + - TRAVIS_TAG + - VCS_BRANCH_NAME + - VCS_COMMIT_ID + - VCS_PULL_REQUEST + - VCS_SLUG + - VCS_TAG + diff --git a/docker-entry.sh b/docker-entry.sh index d1f232c48e..4722f3529e 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -1,6 +1,6 @@ #!/bin/sh -# This is the entrypoint for "make clean". It invokes Tox. If running +# This is the entrypoint for "make test". It invokes Tox. If running # within the CI environment, it also runs codecov set -eu From 07dd8e332f39eefe04543c9cad6e2c70a6eec981 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 15:49:52 -0700 Subject: [PATCH 0062/1164] debug --- docker-compose.yml | 49 +++++++++++++++++++++++----------------------- docker-entry.sh | 3 +++ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b76bf8357c..bc5f6d82af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,28 +39,27 @@ services: depends_on: - "sentinel_3" environment: - - REDIS_MASTER_HOST=master - - REDIS_MASTER_PORT=6379 - - CI_BUILD_ID - - CI_BUILD_URL - - CI_JOB_ID - - CODECOV_ENV - - CODECOV_SLUG - - CODECOV_TOKEN - - CODECOV_URL - - SHIPPABLE - - TRAVIS - - TRAVIS_BRANCH - - TRAVIS_COMMIT - - TRAVIS_JOB_ID - - TRAVIS_JOB_NUMBER - - TRAVIS_OS_NAME - - TRAVIS_PULL_REQUEST - - TRAVIS_REPO_SLUG - - TRAVIS_TAG - - VCS_BRANCH_NAME - - VCS_COMMIT_ID - - VCS_PULL_REQUEST - - VCS_SLUG - - VCS_TAG - + REDIS_MASTER_HOST: master + REDIS_MASTER_PORT: 6379 + CI_BUILD_ID: + CI_BUILD_URL: + CI_JOB_ID: + CODECOV_ENV: + CODECOV_SLUG: + CODECOV_TOKEN: + CODECOV_URL: + SHIPPABLE: + TRAVIS: + TRAVIS_BRANCH: + TRAVIS_COMMIT: + TRAVIS_JOB_ID: + TRAVIS_JOB_NUMBER: + TRAVIS_OS_NAME: + TRAVIS_PULL_REQUEST: + TRAVIS_REPO_SLUG: + TRAVIS_TAG: + VCS_BRANCH_NAME: + VCS_COMMIT_ID: + VCS_PULL_REQUEST: + VCS_SLUG: + VCS_TAG: diff --git a/docker-entry.sh b/docker-entry.sh index 4722f3529e..ddaa81bd86 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -8,6 +8,9 @@ set -eu REDIS_MASTER="${REDIS_MASTER_HOST}":"${REDIS_MASTER_PORT}" echo "Testing against Redis Server: ${REDIS_MASTER}" +#debug +echo `bash <(curl -s https://codecov.io/env)` + # use the wait-for-it util to ensure the server is running before invoking Tox util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTER}"/9 From d582494b2507a7aa18945bb6d3d6ca2a185b0c27 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 15:52:22 -0700 Subject: [PATCH 0063/1164] debug --- docker-entry.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-entry.sh b/docker-entry.sh index ddaa81bd86..17835be1fe 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # This is the entrypoint for "make test". It invokes Tox. If running # within the CI environment, it also runs codecov From e5c7bfdaf5a95ffe13843f0a216750125c0cebf9 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 16:21:39 -0700 Subject: [PATCH 0064/1164] debug --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 4b09cfcced..e285810f87 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,4 +11,4 @@ services: - docker script: - - make test + - echo `bash <(curl -s https://codecov.io/env)` From cbf7b27926fccee325a5ca2e2fb3a28426677620 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 16:25:51 -0700 Subject: [PATCH 0065/1164] debug --- .travis.yml | 2 +- docker-entry.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index e285810f87..4b09cfcced 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,4 +11,4 @@ services: - docker script: - - echo `bash <(curl -s https://codecov.io/env)` + - make test diff --git a/docker-entry.sh b/docker-entry.sh index 17835be1fe..36f25d371c 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -9,7 +9,8 @@ REDIS_MASTER="${REDIS_MASTER_HOST}":"${REDIS_MASTER_PORT}" echo "Testing against Redis Server: ${REDIS_MASTER}" #debug -echo `bash <(curl -s https://codecov.io/env)` +echo "travis job id: ${TRAVIS_JOB_ID}" +exit 0 # use the wait-for-it util to ensure the server is running before invoking Tox util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTER}"/9 From 2bff16169a65de70e8a77510080f314d3c35837c Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 16:28:40 -0700 Subject: [PATCH 0066/1164] debug --- docker-entry.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-entry.sh b/docker-entry.sh index 36f25d371c..320cca78e2 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -9,7 +9,7 @@ REDIS_MASTER="${REDIS_MASTER_HOST}":"${REDIS_MASTER_PORT}" echo "Testing against Redis Server: ${REDIS_MASTER}" #debug -echo "travis job id: ${TRAVIS_JOB_ID}" +env exit 0 # use the wait-for-it util to ensure the server is running before invoking Tox From fe89d3159bfb493cc20085b9180e4450e6f222be Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 16:42:11 -0700 Subject: [PATCH 0067/1164] debug --- docker-compose.yml | 1 + docker-entry.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index bc5f6d82af..247688ad8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: environment: REDIS_MASTER_HOST: master REDIS_MASTER_PORT: 6379 + CI: CI_BUILD_ID: CI_BUILD_URL: CI_JOB_ID: diff --git a/docker-entry.sh b/docker-entry.sh index 320cca78e2..6e3fb91485 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -10,6 +10,7 @@ echo "Testing against Redis Server: ${REDIS_MASTER}" #debug env +codecov exit 0 # use the wait-for-it util to ensure the server is running before invoking Tox From d878df7280be7c4365ee1522d23f37d961735257 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 16 Jul 2020 16:45:24 -0700 Subject: [PATCH 0068/1164] debug --- docker-entry.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docker-entry.sh b/docker-entry.sh index 6e3fb91485..e05cc2296c 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -8,11 +8,6 @@ set -eu REDIS_MASTER="${REDIS_MASTER_HOST}":"${REDIS_MASTER_PORT}" echo "Testing against Redis Server: ${REDIS_MASTER}" -#debug -env -codecov -exit 0 - # use the wait-for-it util to ensure the server is running before invoking Tox util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTER}"/9 From 7da4b55a2831db4d0d0da616322fd491ddb9c3d6 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 17 Jul 2020 19:27:40 +0000 Subject: [PATCH 0069/1164] Try running codecov from tox --- .gitignore | 1 + Dockerfile | 4 +--- docker-entry.sh | 2 +- tox.ini | 16 +++++++++++++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index a9b10171fe..b59bcdf097 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ vagrant/.vagrant .coverage env venv +coverage.xml diff --git a/Dockerfile b/Dockerfile index 3bcc3ec40c..eba66f09ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,6 @@ FROM fkrull/multi-python:latest -RUN apt update && apt install -y pypy pypy-dev pypy3-dev curl -RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && python3.8 get-pip.py -RUN pip install codecov +RUN apt update && apt install -y pypy pypy-dev pypy3-dev WORKDIR /redis-py diff --git a/docker-entry.sh b/docker-entry.sh index e05cc2296c..efcd20521e 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -13,5 +13,5 @@ util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTE # if the TRAVIS env var is defined, invoke "codecov" if [ ! -z ${TRAVIS-} ]; then - codecov + tox -e codecov fi diff --git a/tox.ini b/tox.ini index 30682fb9aa..0a01fef06e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [pytest] -addopts = -s +addopts = -s --cov-report=term-missing --cov redis [tox] minversion = 2.4 @@ -10,6 +10,7 @@ deps = coverage mock pytest >= 2.7.0 + pytest-cov >= 2.10.0 extras = hiredis: hiredis commands = {envpython} -b -m coverage run -m pytest -W always {posargs} @@ -32,3 +33,16 @@ basepython = pypy3 [testenv:pypy3-hiredis] basepython = pypy3 + +[testenv:codecov] +deps = codecov +commands = codecov +passenv = + REDIS_* + CI + CI_* + CODECOV_* + SHIPPABLE + TRAVIS + TRAVIS_* + VCS_* From 884c3d3ddf0e3e9e7d5cbc60656306015c536f88 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 17 Jul 2020 20:06:28 +0000 Subject: [PATCH 0070/1164] Attempt to combine coverage files --- tox.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 0a01fef06e..919ad6a49c 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,9 @@ deps = pytest-cov >= 2.10.0 extras = hiredis: hiredis -commands = {envpython} -b -m coverage run -m pytest -W always {posargs} +commands = + {envpython} -b -m coverage run -p -m pytest -W always {posargs} + {envpython} -b -m coverage combine --append [testenv:flake8] basepython = python3.6 From 07c26758d8a8af28845d534a29b0d35269126108 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 17 Jul 2020 20:13:29 +0000 Subject: [PATCH 0071/1164] Use the -a flag instead of "combine" --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 919ad6a49c..13ffb5b2ad 100644 --- a/tox.ini +++ b/tox.ini @@ -14,8 +14,7 @@ deps = extras = hiredis: hiredis commands = - {envpython} -b -m coverage run -p -m pytest -W always {posargs} - {envpython} -b -m coverage combine --append + {envpython} -b -m coverage run -a -m pytest -W always {posargs} [testenv:flake8] basepython = python3.6 From 4c6cd16bc9ebe75f34a5934cab1e67d1ab8f0bf9 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 17 Jul 2020 20:43:51 +0000 Subject: [PATCH 0072/1164] Go back to "merge" -- -a failed --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 13ffb5b2ad..919ad6a49c 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,8 @@ deps = extras = hiredis: hiredis commands = - {envpython} -b -m coverage run -a -m pytest -W always {posargs} + {envpython} -b -m coverage run -p -m pytest -W always {posargs} + {envpython} -b -m coverage combine --append [testenv:flake8] basepython = python3.6 From 4565195a93cd50400038f6d2a9a3ad759e0811bc Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 17 Jul 2020 21:46:37 +0000 Subject: [PATCH 0073/1164] Remove unnecessary pytest-cov dep --- .coveragerc | 2 ++ docker-entry.sh | 1 + tox.ini | 7 +++++-- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000000..9be06b93ca --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +source = redis diff --git a/docker-entry.sh b/docker-entry.sh index efcd20521e..f1c7baa086 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -10,6 +10,7 @@ echo "Testing against Redis Server: ${REDIS_MASTER}" # use the wait-for-it util to ensure the server is running before invoking Tox util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTER}"/9 +tox -e covreport # if the TRAVIS env var is defined, invoke "codecov" if [ ! -z ${TRAVIS-} ]; then diff --git a/tox.ini b/tox.ini index 919ad6a49c..e36191104a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [pytest] -addopts = -s --cov-report=term-missing --cov redis +addopts = -s [tox] minversion = 2.4 @@ -10,7 +10,6 @@ deps = coverage mock pytest >= 2.7.0 - pytest-cov >= 2.10.0 extras = hiredis: hiredis commands = @@ -48,3 +47,7 @@ passenv = TRAVIS TRAVIS_* VCS_* + +[testenv:covreport] +deps = coverage +commands = coverage report From a4ccbe471b9bd2c1ec86bf6d5b08a110088e4161 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Mon, 20 Jul 2020 13:25:52 -0700 Subject: [PATCH 0074/1164] add the covreport env to the list of default envs tox runs --- docker-entry.sh | 1 - tox.ini | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docker-entry.sh b/docker-entry.sh index f1c7baa086..efcd20521e 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -10,7 +10,6 @@ echo "Testing against Redis Server: ${REDIS_MASTER}" # use the wait-for-it util to ensure the server is running before invoking Tox util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTER}"/9 -tox -e covreport # if the TRAVIS env var is defined, invoke "codecov" if [ ! -z ${TRAVIS-} ]; then diff --git a/tox.ini b/tox.ini index e36191104a..3007f119c7 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ addopts = -s [tox] minversion = 2.4 -envlist = {py27,py35,py36,py37,py38,pypy,pypy3}-{plain,hiredis}, flake8 +envlist = {py27,py35,py36,py37,py38,pypy,pypy3}-{plain,hiredis}, flake8, covreport [testenv] deps = From f24b7f2b66172f9df7143b57d324ffb41bca979b Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Mon, 20 Jul 2020 13:26:15 -0700 Subject: [PATCH 0075/1164] make the slowlog_get test more resilient to multiple clients being connected --- tests/test_commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 65e877ca7a..1f2c97d057 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -433,12 +433,10 @@ def test_slowlog_get(self, r, slowlog): def test_slowlog_get_limit(self, r, slowlog): assert r.slowlog_reset() r.get('foo') - r.get('bar') slowlog = r.slowlog_get(1) assert isinstance(slowlog, list) - commands = [log['command'] for log in slowlog] - assert b'GET foo' not in commands - assert b'GET bar' in commands + # only one command, based on the number we passed to slowlog_get() + assert len(slowlog) == 1 def test_slowlog_length(self, r, slowlog): r.get('foo') From 458ded5ece062a961cd7093933b3ed68e4e7a6c4 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Mon, 20 Jul 2020 13:51:40 -0700 Subject: [PATCH 0076/1164] run the codecov env by default and disable when running outside Travis --- docker-entry.sh | 12 +++++++----- tox.ini | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-entry.sh b/docker-entry.sh index efcd20521e..f63a8c5f14 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -1,17 +1,19 @@ #!/bin/bash # This is the entrypoint for "make test". It invokes Tox. If running -# within the CI environment, it also runs codecov +# outside the CI environment, it disables uploading the coverage report to codecov set -eu REDIS_MASTER="${REDIS_MASTER_HOST}":"${REDIS_MASTER_PORT}" echo "Testing against Redis Server: ${REDIS_MASTER}" +# skip the "codecov" env if not running on Travis +if [ -z ${TRAVIS-} ]; then + echo "Skipping codecov" + export TOX_SKIP_ENV="codecov" +fi + # use the wait-for-it util to ensure the server is running before invoking Tox util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTER}"/9 -# if the TRAVIS env var is defined, invoke "codecov" -if [ ! -z ${TRAVIS-} ]; then - tox -e codecov -fi diff --git a/tox.ini b/tox.ini index 3007f119c7..d1d97f4906 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ addopts = -s [tox] minversion = 2.4 -envlist = {py27,py35,py36,py37,py38,pypy,pypy3}-{plain,hiredis}, flake8, covreport +envlist = {py27,py35,py36,py37,py38,pypy,pypy3}-{plain,hiredis}, flake8, covreport, codecov [testenv] deps = From 1f988d85662ec748a0111d4a6aa4e31effd020cd Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Mon, 20 Jul 2020 14:21:11 -0700 Subject: [PATCH 0077/1164] changelog --- CHANGES | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES b/CHANGES index 03b2354e9d..fc4de47c44 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,6 @@ +* (in development) + * Provide a development and testing environment via docker. Thanks + @abrookins. #1365 * 3.5.3 (June 1, 2020) * Restore try/except clauses to __del__ methods. These will be removed in 4.0 when more explicit resource management if enforced. #1339 From f001927f91ba3c22cb892bbf1e39b34fd47693bc Mon Sep 17 00:00:00 2001 From: Paul Spooren Date: Wed, 22 Jul 2020 12:09:12 -1000 Subject: [PATCH 0078/1164] LPOS: add new command (#1354) Added the LPOS command from Redis 6.0.6 Fixes #1353 --- docker/base/Dockerfile | 2 +- redis/client.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_commands.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index ab973861da..8e952b8e4d 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1 +1 @@ -FROM redis:6.0.5-buster +FROM redis:6.0.6-buster diff --git a/redis/client.py b/redis/client.py index 9653f7d294..5c95e9fa65 100755 --- a/redis/client.py +++ b/redis/client.py @@ -2030,6 +2030,42 @@ def rpushx(self, name, value): "Push ``value`` onto the tail of the list ``name`` if ``name`` exists" return self.execute_command('RPUSHX', name, value) + def lpos(self, name, value, rank=None, count=None, maxlen=None): + """ + Get position of ``value`` within the list ``name`` + + If specified, ``rank`` indicates the "rank" of the first element to + return in case there are multiple copies of ``value`` in the list. + By default, LPOS returns the position of the first occurrence of + ``value`` in the list. When ``rank`` 2, LPOS returns the position of + the second ``value`` in the list. If ``rank`` is negative, LPOS + searches the list in reverse. For example, -1 would return the + position of the last occurrence of ``value`` and -2 would return the + position of the next to last occurrence of ``value``. + + If specified, ``count`` indicates that LPOS should return a list of + up to ``count`` positions. A ``count`` of 2 would return a list of + up to 2 positions. A ``count`` of 0 returns a list of all positions + matching ``value``. When ``count`` is specified and but ``value`` + does not exist in the list, an empty list is returned. + + If specified, ``maxlen`` indicates the maximum number of list + elements to scan. A ``maxlen`` of 1000 will only return the + position(s) of items within the first 1000 entries in the list. + A ``maxlen`` of 0 (the default) will scan the entire list. + """ + pieces = [name, value] + if rank is not None: + pieces.extend(['RANK', rank]) + + if count is not None: + pieces.extend(['COUNT', count]) + + if maxlen is not None: + pieces.extend(['MAXLEN', maxlen]) + + return self.execute_command('LPOS', *pieces) + def sort(self, name, start=None, num=None, by=None, get=None, desc=False, alpha=False, store=None, groups=False): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index c68f14c2c4..91bcbb3098 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1052,6 +1052,38 @@ def test_rpush(self, r): assert r.rpush('a', '3', '4') == 4 assert r.lrange('a', 0, -1) == [b'1', b'2', b'3', b'4'] + @skip_if_server_version_lt('6.0.6') + def test_lpos(self, r): + assert r.rpush('a', 'a', 'b', 'c', '1', '2', '3', 'c', 'c') == 8 + assert r.lpos('a', 'a') == 0 + assert r.lpos('a', 'c') == 2 + + assert r.lpos('a', 'c', rank=1) == 2 + assert r.lpos('a', 'c', rank=2) == 6 + assert r.lpos('a', 'c', rank=4) is None + assert r.lpos('a', 'c', rank=-1) == 7 + assert r.lpos('a', 'c', rank=-2) == 6 + + assert r.lpos('a', 'c', count=0) == [2, 6, 7] + assert r.lpos('a', 'c', count=1) == [2] + assert r.lpos('a', 'c', count=2) == [2, 6] + assert r.lpos('a', 'c', count=100) == [2, 6, 7] + + assert r.lpos('a', 'c', count=0, rank=2) == [6, 7] + assert r.lpos('a', 'c', count=2, rank=-1) == [7, 6] + + assert r.lpos('axxx', 'c', count=0, rank=2) == [] + assert r.lpos('axxx', 'c') is None + + assert r.lpos('a', 'x', count=2) == [] + assert r.lpos('a', 'x') is None + + assert r.lpos('a', 'a', count=0, maxlen=1) == [0] + assert r.lpos('a', 'c', count=0, maxlen=1) == [] + assert r.lpos('a', 'c', count=0, maxlen=3) == [2] + assert r.lpos('a', 'c', count=0, maxlen=3, rank=-1) == [7, 6] + assert r.lpos('a', 'c', count=0, maxlen=7, rank=2) == [6] + def test_rpushx(self, r): assert r.rpushx('a', 'b') == 0 assert r.lrange('a', 0, -1) == [] From 20ec5d771d5116f51735b1d7912447e75c8c3d53 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 22 Jul 2020 16:47:25 -0700 Subject: [PATCH 0079/1164] changelog --- CHANGES | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES b/CHANGES index fc4de47c44..2988fde6a5 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,8 @@ * (in development) * Provide a development and testing environment via docker. Thanks @abrookins. #1365 + * Added support for the LPOS command available in Redis 6.0.6. Thanks + @aparcar #1353/#1354 * 3.5.3 (June 1, 2020) * Restore try/except clauses to __del__ methods. These will be removed in 4.0 when more explicit resource management if enforced. #1339 From c6f13c3b69d32257ab75ba9d824e5b555f91572c Mon Sep 17 00:00:00 2001 From: Jon Banafato Date: Fri, 24 Jul 2020 17:55:33 -0400 Subject: [PATCH 0080/1164] Fix some documentation formatting Fix a few broken links and class references, move a docstring, and fix a code block. --- redis/connection.py | 39 ++++++++++++++++++++------------------- redis/sentinel.py | 22 ++++++++++++---------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/redis/connection.py b/redis/connection.py index b08aee9cd5..22d3902995 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -958,7 +958,18 @@ def to_bool(value): class ConnectionPool(object): - "Generic connection pool" + """ + Create a connection pool. ``If max_connections`` is set, then this + object raises :py:class:`~redis.ConnectionError` when the pool's + limit is reached. + + By default, TCP connections are created unless ``connection_class`` + is specified. Use :py:class:`~redis.UnixDomainSocketConnection` for + unix sockets. + + Any additional keyword arguments are passed to the constructor of + ``connection_class``. + """ @classmethod def from_url(cls, url, db=None, decode_components=False, **kwargs): """ @@ -972,10 +983,10 @@ def from_url(cls, url, db=None, decode_components=False, **kwargs): Three URL schemes are supported: - - ```redis://`` + - `redis:// `_ creates a normal TCP socket connection - - ```rediss://`` + - `rediss:// `_ creates a SSL wrapped TCP socket connection - ``unix://`` creates a Unix Domain Socket connection @@ -1084,16 +1095,6 @@ def from_url(cls, url, db=None, decode_components=False, **kwargs): def __init__(self, connection_class=Connection, max_connections=None, **connection_kwargs): - """ - Create a connection pool. If max_connections is set, then this - object raises redis.ConnectionError when the pool's limit is reached. - - By default, TCP connections are created unless connection_class is - specified. Use redis.UnixDomainSocketConnection for unix sockets. - - Any additional keyword arguments are passed to the constructor of - connection_class. - """ max_connections = max_connections or 2 ** 31 if not isinstance(max_connections, (int, long)) or max_connections < 0: raise ValueError('"max_connections" must be a positive integer') @@ -1291,14 +1292,14 @@ class BlockingConnectionPool(ConnectionPool): >>> client = Redis(connection_pool=BlockingConnectionPool()) It performs the same function as the default - ``:py:class: ~redis.connection.ConnectionPool`` implementation, in that, + :py:class:`~redis.ConnectionPool` implementation, in that, it maintains a pool of reusable connections that can be shared by multiple redis clients (safely across threads if required). The difference is that, in the event that a client tries to get a connection from the pool when all of connections are in use, rather than - raising a ``:py:class: ~redis.exceptions.ConnectionError`` (as the default - ``:py:class: ~redis.connection.ConnectionPool`` implementation does), it + raising a :py:class:`~redis.ConnectionError` (as the default + :py:class:`~redis.ConnectionPool` implementation does), it makes the client wait ("blocks") for a specified number of seconds until a connection becomes available. @@ -1309,11 +1310,11 @@ class BlockingConnectionPool(ConnectionPool): Use ``timeout`` to tell it either how many seconds to wait for a connection to become available, or to block forever: - # Block forever. + >>> # Block forever. >>> pool = BlockingConnectionPool(timeout=None) - # Raise a ``ConnectionError`` after five seconds if a connection is - # not available. + >>> # Raise a ``ConnectionError`` after five seconds if a connection is + >>> # not available. >>> pool = BlockingConnectionPool(timeout=5) """ def __init__(self, max_connections=50, timeout=20, diff --git a/redis/sentinel.py b/redis/sentinel.py index 2b212ea8bf..203c859bcc 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -244,18 +244,20 @@ def master_for(self, service_name, redis_class=Redis, """ Returns a redis client instance for the ``service_name`` master. - A SentinelConnectionPool class is used to retrive the master's - address before establishing a new connection. + A :py:class:`~redis.sentinel.SentinelConnectionPool` class is + used to retrive the master's address before establishing a new + connection. NOTE: If the master's address has changed, any cached connections to the old master are closed. - By default clients will be a redis.Redis instance. Specify a - different class to the ``redis_class`` argument if you desire - something different. + By default clients will be a :py:class:`~redis.Redis` instance. + Specify a different class to the ``redis_class`` argument if you + desire something different. - The ``connection_pool_class`` specifies the connection pool to use. - The SentinelConnectionPool will be used by default. + The ``connection_pool_class`` specifies the connection pool to + use. The :py:class:`~redis.sentinel.SentinelConnectionPool` + will be used by default. All other keyword arguments are merged with any connection_kwargs passed to this class and passed to the connection pool as keyword @@ -275,9 +277,9 @@ def slave_for(self, service_name, redis_class=Redis, A SentinelConnectionPool class is used to retrive the slave's address before establishing a new connection. - By default clients will be a redis.Redis instance. Specify a - different class to the ``redis_class`` argument if you desire - something different. + By default clients will be a :py:class:`~redis.Redis` instance. + Specify a different class to the ``redis_class`` argument if you + desire something different. The ``connection_pool_class`` specifies the connection pool to use. The SentinelConnectionPool will be used by default. From 8c5a41baf0bd2a1388d601e5b49d06b91997ccb8 Mon Sep 17 00:00:00 2001 From: Jon Dufresne Date: Thu, 6 Aug 2020 15:15:02 -0700 Subject: [PATCH 0081/1164] Remove support for end-of-life Python 2.7 (#1318) Remove support for end-of-life Python 2.7 Python 2.7 is end of life. It is no longer receiving bug fixes, including for security issues. Python 2.7 went EOL on 2020-01-01. For additional details on support Python versions, see: Supported: https://devguide.python.org/#status-of-python-branches EOL: https://devguide.python.org/devcycle/#end-of-life-branches Removing support for EOL Pythons will reduce testing and maintenance resources while allowing the library to move towards a modern Python 3 style. Python 2.7 users can continue to use the previous version of redis-py. Was able to simplify the code: - Removed redis._compat module - Removed __future__ imports - Removed object from class definition (all classes are new style) - Removed long (Python 3 unified numeric types) - Removed deprecated __nonzero__ method - Use simpler Python 3 super() syntax - Use unified OSError exception - Use yield from syntax Co-authored-by: Andy McCurdy --- .dockerignore | 2 + CHANGES | 1 + README.rst | 2 +- benchmarks/base.py | 5 +- benchmarks/basic_operations.py | 12 +- benchmarks/command_packer_benchmark.py | 5 +- docs/conf.py | 26 +- redis/_compat.py | 188 ----------- redis/client.py | 420 ++++++++++++------------- redis/connection.py | 129 +++----- redis/lock.py | 10 +- redis/sentinel.py | 20 +- redis/utils.py | 15 +- setup.cfg | 8 +- tests/conftest.py | 8 +- tests/test_commands.py | 38 ++- tests/test_connection.py | 2 +- tests/test_connection_pool.py | 20 +- tests/test_encoding.py | 31 +- tests/test_lock.py | 6 +- tests/test_monitor.py | 8 +- tests/test_multiprocessing.py | 2 +- tests/test_pipeline.py | 27 +- tests/test_pubsub.py | 36 +-- tests/test_scripting.py | 3 +- tests/test_sentinel.py | 5 +- tox.ini | 3 +- 27 files changed, 378 insertions(+), 654 deletions(-) delete mode 100644 redis/_compat.py diff --git a/.dockerignore b/.dockerignore index 08ea4a6294..7b9bc9b2df 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ **/__pycache__ **/*.pyc .tox +.coverage +.coverage.* diff --git a/CHANGES b/CHANGES index 2988fde6a5..b164991b8f 100644 --- a/CHANGES +++ b/CHANGES @@ -1,4 +1,5 @@ * (in development) + * Removed support for end of life Python 2.7. * Provide a development and testing environment via docker. Thanks @abrookins. #1365 * Added support for the LPOS command available in Redis 6.0.6. Thanks diff --git a/README.rst b/README.rst index 4f2cec840b..438e33e513 100644 --- a/README.rst +++ b/README.rst @@ -87,7 +87,7 @@ provide an upgrade path for users migrating from 2.X to 3.0. Python Version Support ^^^^^^^^^^^^^^^^^^^^^^ -redis-py 3.0 supports Python 2.7 and Python 3.5+. +redis-py supports Python 3.5+. Client Classes: Redis and StrictRedis diff --git a/benchmarks/base.py b/benchmarks/base.py index 44e93414ff..8c13afe34d 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -3,10 +3,9 @@ import redis import sys import timeit -from redis._compat import izip -class Benchmark(object): +class Benchmark: ARGUMENTS = () def __init__(self): @@ -34,7 +33,7 @@ def run_benchmark(self): group_names = [group['name'] for group in self.ARGUMENTS] group_values = [group['values'] for group in self.ARGUMENTS] for value_set in itertools.product(*group_values): - pairs = list(izip(group_names, value_set)) + pairs = list(zip(group_names, value_set)) arg_string = ', '.join(['%s=%s' % (p[0], p[1]) for p in pairs]) sys.stdout.write('Benchmark: %s... ' % arg_string) sys.stdout.flush() diff --git a/benchmarks/basic_operations.py b/benchmarks/basic_operations.py index a4b675d7ba..9446343251 100644 --- a/benchmarks/basic_operations.py +++ b/benchmarks/basic_operations.py @@ -1,13 +1,8 @@ -from __future__ import print_function import redis import time -import sys from functools import wraps from argparse import ArgumentParser -if sys.version_info[0] == 3: - long = int - def parse_args(): parser = ArgumentParser() @@ -47,9 +42,9 @@ def run(): def timer(func): @wraps(func) def wrapper(*args, **kwargs): - start = time.clock() + start = time.monotonic() ret = func(*args, **kwargs) - duration = time.clock() - start + duration = time.monotonic() - start if 'num' in kwargs: count = kwargs['num'] else: @@ -57,7 +52,7 @@ def wrapper(*args, **kwargs): print('{} - {} Requests'.format(func.__name__, count)) print('Duration = {}'.format(duration)) print('Rate = {}'.format(count/duration)) - print('') + print() return ret return wrapper @@ -185,7 +180,6 @@ def hmset(conn, num, pipeline_size, data_size): set_data = {'str_value': 'string', 'int_value': 123456, - 'long_value': long(123456), 'float_value': 123456.0} for i in range(num): conn.hmset('hmset_key', set_data) diff --git a/benchmarks/command_packer_benchmark.py b/benchmarks/command_packer_benchmark.py index 1216df6775..823a8c8469 100644 --- a/benchmarks/command_packer_benchmark.py +++ b/benchmarks/command_packer_benchmark.py @@ -1,7 +1,6 @@ import socket from redis.connection import (Connection, SYM_STAR, SYM_DOLLAR, SYM_EMPTY, SYM_CRLF) -from redis._compat import imap from base import Benchmark @@ -29,7 +28,7 @@ def pack_command(self, *args): args_output = SYM_EMPTY.join([ SYM_EMPTY.join( (SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF, k, SYM_CRLF)) - for k in imap(self.encoder.encode, args)]) + for k in map(self.encoder.encode, args)]) output = SYM_EMPTY.join( (SYM_STAR, str(len(args)).encode(), SYM_CRLF, args_output)) return output @@ -61,7 +60,7 @@ def pack_command(self, *args): buff = SYM_EMPTY.join( (SYM_STAR, str(len(args)).encode(), SYM_CRLF)) - for k in imap(self.encoder.encode, args): + for k in map(self.encoder.encode, args): if len(buff) > 6000 or len(k) > 6000: buff = SYM_EMPTY.join( (buff, SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF)) diff --git a/docs/conf.py b/docs/conf.py index 690be037db..3eb3f33ef2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- -# # redis-py documentation build configuration file, created by # sphinx-quickstart on Fri Feb 8 00:47:08 2013. # @@ -43,8 +41,8 @@ master_doc = 'index' # General information about the project. -project = u'redis-py' -copyright = u'2016, Andy McCurdy' +project = 'redis-py' +copyright = '2016, Andy McCurdy' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -188,8 +186,8 @@ # (source start file, target name, title, author, documentclass # [howto/manual]). latex_documents = [ - ('index', 'redis-py.tex', u'redis-py Documentation', - u'Andy McCurdy', 'manual'), + ('index', 'redis-py.tex', 'redis-py Documentation', + 'Andy McCurdy', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -218,8 +216,8 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'redis-py', u'redis-py Documentation', - [u'Andy McCurdy'], 1) + ('index', 'redis-py', 'redis-py Documentation', + ['Andy McCurdy'], 1) ] # If true, show URL addresses after external links. @@ -232,8 +230,8 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'redis-py', u'redis-py Documentation', - u'Andy McCurdy', 'redis-py', + ('index', 'redis-py', 'redis-py Documentation', + 'Andy McCurdy', 'redis-py', 'One line description of project.', 'Miscellaneous'), ] @@ -246,7 +244,7 @@ # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' -epub_title = u'redis-py' -epub_author = u'Andy McCurdy' -epub_publisher = u'Andy McCurdy' -epub_copyright = u'2011, Andy McCurdy' +epub_title = 'redis-py' +epub_author = 'Andy McCurdy' +epub_publisher = 'Andy McCurdy' +epub_copyright = '2011, Andy McCurdy' diff --git a/redis/_compat.py b/redis/_compat.py deleted file mode 100644 index a0036de31e..0000000000 --- a/redis/_compat.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Internal module for Python 2 backwards compatibility.""" -# flake8: noqa -import errno -import socket -import sys - - -def sendall(sock, *args, **kwargs): - return sock.sendall(*args, **kwargs) - - -def shutdown(sock, *args, **kwargs): - return sock.shutdown(*args, **kwargs) - - -def ssl_wrap_socket(context, sock, *args, **kwargs): - return context.wrap_socket(sock, *args, **kwargs) - - -# For Python older than 3.5, retry EINTR. -if sys.version_info[0] < 3 or (sys.version_info[0] == 3 and - sys.version_info[1] < 5): - # Adapted from https://bugs.python.org/review/23863/patch/14532/54418 - import time - - # Wrapper for handling interruptable system calls. - def _retryable_call(s, func, *args, **kwargs): - # Some modules (SSL) use the _fileobject wrapper directly and - # implement a smaller portion of the socket interface, thus we - # need to let them continue to do so. - timeout, deadline = None, 0.0 - attempted = False - try: - timeout = s.gettimeout() - except AttributeError: - pass - - if timeout: - deadline = time.time() + timeout - - try: - while True: - if attempted and timeout: - now = time.time() - if now >= deadline: - raise socket.error(errno.EWOULDBLOCK, "timed out") - else: - # Overwrite the timeout on the socket object - # to take into account elapsed time. - s.settimeout(deadline - now) - try: - attempted = True - return func(*args, **kwargs) - except socket.error as e: - if e.args[0] == errno.EINTR: - continue - raise - finally: - # Set the existing timeout back for future - # calls. - if timeout: - s.settimeout(timeout) - - def recv(sock, *args, **kwargs): - return _retryable_call(sock, sock.recv, *args, **kwargs) - - def recv_into(sock, *args, **kwargs): - return _retryable_call(sock, sock.recv_into, *args, **kwargs) - -else: # Python 3.5 and above automatically retry EINTR - def recv(sock, *args, **kwargs): - return sock.recv(*args, **kwargs) - - def recv_into(sock, *args, **kwargs): - return sock.recv_into(*args, **kwargs) - -if sys.version_info[0] < 3: - # In Python 3, the ssl module raises socket.timeout whereas it raises - # SSLError in Python 2. For compatibility between versions, ensure - # socket.timeout is raised for both. - import functools - - try: - from ssl import SSLError as _SSLError - except ImportError: - class _SSLError(Exception): - """A replacement in case ssl.SSLError is not available.""" - pass - - _EXPECTED_SSL_TIMEOUT_MESSAGES = ( - "The handshake operation timed out", - "The read operation timed out", - "The write operation timed out", - ) - - def _handle_ssl_timeout(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except _SSLError as e: - message = len(e.args) == 1 and unicode(e.args[0]) or '' - if any(x in message for x in _EXPECTED_SSL_TIMEOUT_MESSAGES): - # Raise socket.timeout for compatibility with Python 3. - raise socket.timeout(*e.args) - raise - return wrapper - - recv = _handle_ssl_timeout(recv) - recv_into = _handle_ssl_timeout(recv_into) - sendall = _handle_ssl_timeout(sendall) - shutdown = _handle_ssl_timeout(shutdown) - ssl_wrap_socket = _handle_ssl_timeout(ssl_wrap_socket) - -if sys.version_info[0] < 3: - from urllib import unquote - from urlparse import parse_qs, urlparse - from itertools import imap, izip - from string import letters as ascii_letters - from Queue import Queue - - # special unicode handling for python2 to avoid UnicodeDecodeError - def safe_unicode(obj, *args): - """ return the unicode representation of obj """ - try: - return unicode(obj, *args) - except UnicodeDecodeError: - # obj is byte string - ascii_text = str(obj).encode('string_escape') - return unicode(ascii_text) - - def iteritems(x): - return x.iteritems() - - def iterkeys(x): - return x.iterkeys() - - def itervalues(x): - return x.itervalues() - - def nativestr(x): - return x if isinstance(x, str) else x.encode('utf-8', 'replace') - - def next(x): - return x.next() - - unichr = unichr - xrange = xrange - basestring = basestring - unicode = unicode - long = long - BlockingIOError = socket.error -else: - from urllib.parse import parse_qs, unquote, urlparse - from string import ascii_letters - from queue import Queue - - def iteritems(x): - return iter(x.items()) - - def iterkeys(x): - return iter(x.keys()) - - def itervalues(x): - return iter(x.values()) - - def nativestr(x): - return x if isinstance(x, str) else x.decode('utf-8', 'replace') - - def safe_unicode(value): - if isinstance(value, bytes): - value = value.decode('utf-8', 'replace') - return str(value) - - next = next - unichr = chr - imap = map - izip = zip - xrange = range - basestring = str - unicode = str - long = int - BlockingIOError = BlockingIOError - -try: # Python 3 - from queue import LifoQueue, Empty, Full -except ImportError: # Python 2 - from Queue import LifoQueue, Empty, Full diff --git a/redis/client.py b/redis/client.py index 5c95e9fa65..881fd65bd1 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1,4 +1,3 @@ -from __future__ import unicode_literals from itertools import chain import datetime import warnings @@ -7,8 +6,6 @@ import time as mod_time import re import hashlib -from redis._compat import (basestring, imap, iteritems, iterkeys, - itervalues, izip, long, nativestr, safe_unicode) from redis.connection import (ConnectionPool, UnixDomainSocketConnection, SSLConnection) from redis.lock import Lock @@ -24,6 +21,7 @@ WatchError, ModuleError, ) +from redis.utils import safe_str, str_if_bytes SYM_EMPTY = b'' EMPTY_RESPONSE = 'EMPTY_RESPONSE' @@ -35,7 +33,7 @@ def list_or_args(keys, args): iter(keys) # a string or bytes instance can be iterated, but indicates # keys wasn't passed as a list - if isinstance(keys, (basestring, bytes)): + if isinstance(keys, (bytes, str)): keys = [keys] else: keys = list(keys) @@ -61,45 +59,38 @@ def string_keys_to_dict(key_string, callback): return dict.fromkeys(key_string.split(), callback) -def dict_merge(*dicts): - merged = {} - for d in dicts: - merged.update(d) - return merged - - class CaseInsensitiveDict(dict): "Case insensitive dict implementation. Assumes string keys only." def __init__(self, data): - for k, v in iteritems(data): + for k, v in data.items(): self[k.upper()] = v def __contains__(self, k): - return super(CaseInsensitiveDict, self).__contains__(k.upper()) + return super().__contains__(k.upper()) def __delitem__(self, k): - super(CaseInsensitiveDict, self).__delitem__(k.upper()) + super().__delitem__(k.upper()) def __getitem__(self, k): - return super(CaseInsensitiveDict, self).__getitem__(k.upper()) + return super().__getitem__(k.upper()) def get(self, k, default=None): - return super(CaseInsensitiveDict, self).get(k.upper(), default) + return super().get(k.upper(), default) def __setitem__(self, k, v): - super(CaseInsensitiveDict, self).__setitem__(k.upper(), v) + super().__setitem__(k.upper(), v) def update(self, data): data = CaseInsensitiveDict(data) - super(CaseInsensitiveDict, self).update(data) + super().update(data) def parse_debug_object(response): "Parse the results of Redis's DEBUG OBJECT command into a Python dict" # The 'type' of the object is the first item in the response, but isn't # prefixed with a name - response = nativestr(response) + response = str_if_bytes(response) response = 'type:' + response response = dict(kv.split(':') for kv in response.split()) @@ -123,7 +114,7 @@ def parse_object(response, infotype): def parse_info(response): "Parse the result of Redis's INFO command into a Python dict" info = {} - response = nativestr(response) + response = str_if_bytes(response) def get_value(value): if ',' not in value or '=' not in value: @@ -163,7 +154,7 @@ def parse_memory_stats(response, **kwargs): stats = pairs_to_dict(response, decode_keys=True, decode_string_values=True) - for key, value in iteritems(stats): + for key, value in stats.items(): if key.startswith('db.'): stats[key] = pairs_to_dict(value, decode_keys=True, @@ -211,52 +202,48 @@ def parse_sentinel_state(item): def parse_sentinel_master(response): - return parse_sentinel_state(imap(nativestr, response)) + return parse_sentinel_state(map(str_if_bytes, response)) def parse_sentinel_masters(response): result = {} for item in response: - state = parse_sentinel_state(imap(nativestr, item)) + state = parse_sentinel_state(map(str_if_bytes, item)) result[state['name']] = state return result def parse_sentinel_slaves_and_sentinels(response): - return [parse_sentinel_state(imap(nativestr, item)) for item in response] + return [parse_sentinel_state(map(str_if_bytes, item)) for item in response] def parse_sentinel_get_master(response): return response and (response[0], int(response[1])) or None -def nativestr_if_bytes(value): - return nativestr(value) if isinstance(value, bytes) else value - - def pairs_to_dict(response, decode_keys=False, decode_string_values=False): "Create a dict given a list of key/value pairs" if response is None: return {} if decode_keys or decode_string_values: # the iter form is faster, but I don't know how to make that work - # with a nativestr() map + # with a str_if_bytes() map keys = response[::2] if decode_keys: - keys = imap(nativestr, keys) + keys = map(str_if_bytes, keys) values = response[1::2] if decode_string_values: - values = imap(nativestr_if_bytes, values) - return dict(izip(keys, values)) + values = map(str_if_bytes, values) + return dict(zip(keys, values)) else: it = iter(response) - return dict(izip(it, it)) + return dict(zip(it, it)) def pairs_to_dict_typed(response, type_info): it = iter(response) result = {} - for key, value in izip(it, it): + for key, value in zip(it, it): if key in type_info: try: value = type_info[key](value) @@ -277,7 +264,7 @@ def zset_score_pairs(response, **options): return response score_cast_func = options.get('score_cast_func', float) it = iter(response) - return list(izip(it, imap(score_cast_func, it))) + return list(zip(it, map(score_cast_func, it))) def sort_return_tuples(response, **options): @@ -288,7 +275,7 @@ def sort_return_tuples(response, **options): if not response or not options.get('groups'): return response n = options['groups'] - return list(izip(*[response[i::n] for i in range(n)])) + return list(zip(*[response[i::n] for i in range(n)])) def int_or_none(response): @@ -297,12 +284,6 @@ def int_or_none(response): return int(response) -def nativestr_or_none(response): - if response is None: - return None - return nativestr(response) - - def parse_stream_list(response): if response is None: return None @@ -315,12 +296,12 @@ def parse_stream_list(response): return data -def pairs_to_dict_with_nativestr_keys(response): +def pairs_to_dict_with_str_keys(response): return pairs_to_dict(response, decode_keys=True) def parse_list_of_dicts(response): - return list(imap(pairs_to_dict_with_nativestr_keys, response)) + return list(map(pairs_to_dict_with_str_keys, response)) def parse_xclaim(response, **options): @@ -349,7 +330,7 @@ def parse_xread(response): def parse_xpending(response, **options): if options.get('parse_detail', False): return parse_xpending_range(response) - consumers = [{'name': n, 'pending': long(p)} for n, p in response[3] or []] + consumers = [{'name': n, 'pending': int(p)} for n, p in response[3] or []] return { 'pending': response[0], 'min': response[1], @@ -360,7 +341,7 @@ def parse_xpending(response, **options): def parse_xpending_range(response): k = ('message_id', 'consumer', 'time_since_delivered', 'times_delivered') - return [dict(izip(k, r)) for r in response] + return [dict(zip(k, r)) for r in response] def float_or_none(response): @@ -370,7 +351,7 @@ def float_or_none(response): def bool_ok(response): - return nativestr(response) == 'OK' + return str_if_bytes(response) == 'OK' def parse_zadd(response, **options): @@ -383,32 +364,32 @@ def parse_zadd(response, **options): def parse_client_list(response, **options): clients = [] - for c in nativestr(response).splitlines(): + for c in str_if_bytes(response).splitlines(): # Values might contain '=' clients.append(dict(pair.split('=', 1) for pair in c.split(' '))) return clients def parse_config_get(response, **options): - response = [nativestr(i) if i is not None else None for i in response] + response = [str_if_bytes(i) if i is not None else None for i in response] return response and pairs_to_dict(response) or {} def parse_scan(response, **options): cursor, r = response - return long(cursor), r + return int(cursor), r def parse_hscan(response, **options): cursor, r = response - return long(cursor), r and pairs_to_dict(r) or {} + return int(cursor), r and pairs_to_dict(r) or {} def parse_zscan(response, **options): score_cast_func = options.get('score_cast_func', float) cursor, r = response it = iter(r) - return long(cursor), list(izip(it, imap(score_cast_func, it))) + return int(cursor), list(zip(it, map(score_cast_func, it))) def parse_slowlog_get(response, **options): @@ -422,7 +403,7 @@ def parse_slowlog_get(response, **options): def parse_cluster_info(response, **options): - response = nativestr(response) + response = str_if_bytes(response) return dict(line.split(':') for line in response.splitlines() if line) @@ -445,10 +426,7 @@ def _parse_node_line(line): def parse_cluster_nodes(response, **options): - response = nativestr(response) - raw_lines = response - if isinstance(response, basestring): - raw_lines = response.splitlines() + raw_lines = str_if_bytes(response).splitlines() return dict(_parse_node_line(line) for line in raw_lines) @@ -488,9 +466,9 @@ def parse_pubsub_numsub(response, **options): def parse_client_kill(response, **options): - if isinstance(response, (long, int)): - return int(response) - return nativestr(response) == 'OK' + if isinstance(response, int): + return response + return str_if_bytes(response) == 'OK' def parse_acl_getuser(response, **options): @@ -499,9 +477,9 @@ def parse_acl_getuser(response, **options): data = pairs_to_dict(response, decode_keys=True) # convert everything but user-defined data in 'keys' to native strings - data['flags'] = list(map(nativestr, data['flags'])) - data['passwords'] = list(map(nativestr, data['passwords'])) - data['commands'] = nativestr(data['commands']) + data['flags'] = list(map(str_if_bytes, data['flags'])) + data['passwords'] = list(map(str_if_bytes, data['passwords'])) + data['commands'] = str_if_bytes(data['commands']) # split 'commands' into separate 'categories' and 'commands' lists commands, categories = [], [] @@ -523,7 +501,7 @@ def parse_module_result(response): return True -class Redis(object): +class Redis: """ Implementation of the Redis protocol. @@ -533,13 +511,13 @@ class Redis(object): Connection and Pipeline derive from this, implementing how the commands are sent and received to the Redis server """ - RESPONSE_CALLBACKS = dict_merge( - string_keys_to_dict( + RESPONSE_CALLBACKS = { + **string_keys_to_dict( 'AUTH EXPIRE EXPIREAT HEXISTS HMSET MOVE MSETNX PERSIST ' 'PSETEX RENAMENX SISMEMBER SMOVE SETEX SETNX', bool ), - string_keys_to_dict( + **string_keys_to_dict( 'BITCOUNT BITPOS DECRBY DEL EXISTS GEOADD GETBIT HDEL HLEN ' 'HSTRLEN INCRBY LINSERT LLEN LPUSHX PFADD PFCOUNT RPUSHX SADD ' 'SCARD SDIFFSTORE SETBIT SETRANGE SINTERSTORE SREM STRLEN ' @@ -547,128 +525,126 @@ class Redis(object): 'ZREMRANGEBYLEX ZREMRANGEBYRANK ZREMRANGEBYSCORE', int ), - string_keys_to_dict( + **string_keys_to_dict( 'INCRBYFLOAT HINCRBYFLOAT', float ), - string_keys_to_dict( + **string_keys_to_dict( # these return OK, or int if redis-server is >=1.3.4 'LPUSH RPUSH', - lambda r: isinstance(r, (long, int)) and r or nativestr(r) == 'OK' + lambda r: isinstance(r, int) and r or str_if_bytes(r) == 'OK' ), - string_keys_to_dict('SORT', sort_return_tuples), - string_keys_to_dict('ZSCORE ZINCRBY GEODIST', float_or_none), - string_keys_to_dict( + **string_keys_to_dict('SORT', sort_return_tuples), + **string_keys_to_dict('ZSCORE ZINCRBY GEODIST', float_or_none), + **string_keys_to_dict( 'FLUSHALL FLUSHDB LSET LTRIM MSET PFMERGE READONLY READWRITE ' 'RENAME SAVE SELECT SHUTDOWN SLAVEOF SWAPDB WATCH UNWATCH ', bool_ok ), - string_keys_to_dict('BLPOP BRPOP', lambda r: r and tuple(r) or None), - string_keys_to_dict( + **string_keys_to_dict('BLPOP BRPOP', lambda r: r and tuple(r) or None), + **string_keys_to_dict( 'SDIFF SINTER SMEMBERS SUNION', lambda r: r and set(r) or set() ), - string_keys_to_dict( + **string_keys_to_dict( 'ZPOPMAX ZPOPMIN ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE', zset_score_pairs ), - string_keys_to_dict('BZPOPMIN BZPOPMAX', \ - lambda r: r and (r[0], r[1], float(r[2])) or None), - string_keys_to_dict('ZRANK ZREVRANK', int_or_none), - string_keys_to_dict('XREVRANGE XRANGE', parse_stream_list), - string_keys_to_dict('XREAD XREADGROUP', parse_xread), - string_keys_to_dict('BGREWRITEAOF BGSAVE', lambda r: True), - { - 'ACL CAT': lambda r: list(map(nativestr, r)), - 'ACL DELUSER': int, - 'ACL GENPASS': nativestr, - 'ACL GETUSER': parse_acl_getuser, - 'ACL LIST': lambda r: list(map(nativestr, r)), - 'ACL LOAD': bool_ok, - 'ACL SAVE': bool_ok, - 'ACL SETUSER': bool_ok, - 'ACL USERS': lambda r: list(map(nativestr, r)), - 'ACL WHOAMI': nativestr, - 'CLIENT GETNAME': lambda r: r and nativestr(r), - 'CLIENT ID': int, - 'CLIENT KILL': parse_client_kill, - 'CLIENT LIST': parse_client_list, - 'CLIENT SETNAME': bool_ok, - 'CLIENT UNBLOCK': lambda r: r and int(r) == 1 or False, - 'CLIENT PAUSE': bool_ok, - 'CLUSTER ADDSLOTS': bool_ok, - 'CLUSTER COUNT-FAILURE-REPORTS': lambda x: int(x), - 'CLUSTER COUNTKEYSINSLOT': lambda x: int(x), - 'CLUSTER DELSLOTS': bool_ok, - 'CLUSTER FAILOVER': bool_ok, - 'CLUSTER FORGET': bool_ok, - 'CLUSTER INFO': parse_cluster_info, - 'CLUSTER KEYSLOT': lambda x: int(x), - 'CLUSTER MEET': bool_ok, - 'CLUSTER NODES': parse_cluster_nodes, - 'CLUSTER REPLICATE': bool_ok, - 'CLUSTER RESET': bool_ok, - 'CLUSTER SAVECONFIG': bool_ok, - 'CLUSTER SET-CONFIG-EPOCH': bool_ok, - 'CLUSTER SETSLOT': bool_ok, - 'CLUSTER SLAVES': parse_cluster_nodes, - 'CONFIG GET': parse_config_get, - 'CONFIG RESETSTAT': bool_ok, - 'CONFIG SET': bool_ok, - 'DEBUG OBJECT': parse_debug_object, - 'GEOHASH': lambda r: list(map(nativestr_or_none, r)), - 'GEOPOS': lambda r: list(map(lambda ll: (float(ll[0]), - float(ll[1])) - if ll is not None else None, r)), - 'GEORADIUS': parse_georadius_generic, - 'GEORADIUSBYMEMBER': parse_georadius_generic, - 'HGETALL': lambda r: r and pairs_to_dict(r) or {}, - 'HSCAN': parse_hscan, - 'INFO': parse_info, - 'LASTSAVE': timestamp_to_datetime, - 'MEMORY PURGE': bool_ok, - 'MEMORY STATS': parse_memory_stats, - 'MEMORY USAGE': int_or_none, - 'OBJECT': parse_object, - 'PING': lambda r: nativestr(r) == 'PONG', - 'PUBSUB NUMSUB': parse_pubsub_numsub, - 'RANDOMKEY': lambda r: r and r or None, - 'SCAN': parse_scan, - 'SCRIPT EXISTS': lambda r: list(imap(bool, r)), - 'SCRIPT FLUSH': bool_ok, - 'SCRIPT KILL': bool_ok, - 'SCRIPT LOAD': nativestr, - 'SENTINEL GET-MASTER-ADDR-BY-NAME': parse_sentinel_get_master, - 'SENTINEL MASTER': parse_sentinel_master, - 'SENTINEL MASTERS': parse_sentinel_masters, - 'SENTINEL MONITOR': bool_ok, - 'SENTINEL REMOVE': bool_ok, - 'SENTINEL SENTINELS': parse_sentinel_slaves_and_sentinels, - 'SENTINEL SET': bool_ok, - 'SENTINEL SLAVES': parse_sentinel_slaves_and_sentinels, - 'SET': lambda r: r and nativestr(r) == 'OK', - 'SLOWLOG GET': parse_slowlog_get, - 'SLOWLOG LEN': int, - 'SLOWLOG RESET': bool_ok, - 'SSCAN': parse_scan, - 'TIME': lambda x: (int(x[0]), int(x[1])), - 'XCLAIM': parse_xclaim, - 'XGROUP CREATE': bool_ok, - 'XGROUP DELCONSUMER': int, - 'XGROUP DESTROY': bool, - 'XGROUP SETID': bool_ok, - 'XINFO CONSUMERS': parse_list_of_dicts, - 'XINFO GROUPS': parse_list_of_dicts, - 'XINFO STREAM': parse_xinfo_stream, - 'XPENDING': parse_xpending, - 'ZADD': parse_zadd, - 'ZSCAN': parse_zscan, - 'MODULE LOAD': parse_module_result, - 'MODULE UNLOAD': parse_module_result, - 'MODULE LIST': lambda response: [pairs_to_dict(module) - for module in response], - } - ) + **string_keys_to_dict('BZPOPMIN BZPOPMAX', \ + lambda r: + r and (r[0], r[1], float(r[2])) or None), + **string_keys_to_dict('ZRANK ZREVRANK', int_or_none), + **string_keys_to_dict('XREVRANGE XRANGE', parse_stream_list), + **string_keys_to_dict('XREAD XREADGROUP', parse_xread), + **string_keys_to_dict('BGREWRITEAOF BGSAVE', lambda r: True), + 'ACL CAT': lambda r: list(map(str_if_bytes, r)), + 'ACL DELUSER': int, + 'ACL GENPASS': str_if_bytes, + 'ACL GETUSER': parse_acl_getuser, + 'ACL LIST': lambda r: list(map(str_if_bytes, r)), + 'ACL LOAD': bool_ok, + 'ACL SAVE': bool_ok, + 'ACL SETUSER': bool_ok, + 'ACL USERS': lambda r: list(map(str_if_bytes, r)), + 'ACL WHOAMI': str_if_bytes, + 'CLIENT GETNAME': str_if_bytes, + 'CLIENT ID': int, + 'CLIENT KILL': parse_client_kill, + 'CLIENT LIST': parse_client_list, + 'CLIENT SETNAME': bool_ok, + 'CLIENT UNBLOCK': lambda r: r and int(r) == 1 or False, + 'CLIENT PAUSE': bool_ok, + 'CLUSTER ADDSLOTS': bool_ok, + 'CLUSTER COUNT-FAILURE-REPORTS': lambda x: int(x), + 'CLUSTER COUNTKEYSINSLOT': lambda x: int(x), + 'CLUSTER DELSLOTS': bool_ok, + 'CLUSTER FAILOVER': bool_ok, + 'CLUSTER FORGET': bool_ok, + 'CLUSTER INFO': parse_cluster_info, + 'CLUSTER KEYSLOT': lambda x: int(x), + 'CLUSTER MEET': bool_ok, + 'CLUSTER NODES': parse_cluster_nodes, + 'CLUSTER REPLICATE': bool_ok, + 'CLUSTER RESET': bool_ok, + 'CLUSTER SAVECONFIG': bool_ok, + 'CLUSTER SET-CONFIG-EPOCH': bool_ok, + 'CLUSTER SETSLOT': bool_ok, + 'CLUSTER SLAVES': parse_cluster_nodes, + 'CONFIG GET': parse_config_get, + 'CONFIG RESETSTAT': bool_ok, + 'CONFIG SET': bool_ok, + 'DEBUG OBJECT': parse_debug_object, + 'GEOHASH': lambda r: list(map(str_if_bytes, r)), + 'GEOPOS': lambda r: list(map(lambda ll: (float(ll[0]), + float(ll[1])) + if ll is not None else None, r)), + 'GEORADIUS': parse_georadius_generic, + 'GEORADIUSBYMEMBER': parse_georadius_generic, + 'HGETALL': lambda r: r and pairs_to_dict(r) or {}, + 'HSCAN': parse_hscan, + 'INFO': parse_info, + 'LASTSAVE': timestamp_to_datetime, + 'MEMORY PURGE': bool_ok, + 'MEMORY STATS': parse_memory_stats, + 'MEMORY USAGE': int_or_none, + 'MODULE LOAD': parse_module_result, + 'MODULE UNLOAD': parse_module_result, + 'MODULE LIST': lambda r: [pairs_to_dict(m) for m in r], + 'OBJECT': parse_object, + 'PING': lambda r: str_if_bytes(r) == 'PONG', + 'PUBSUB NUMSUB': parse_pubsub_numsub, + 'RANDOMKEY': lambda r: r and r or None, + 'SCAN': parse_scan, + 'SCRIPT EXISTS': lambda r: list(map(bool, r)), + 'SCRIPT FLUSH': bool_ok, + 'SCRIPT KILL': bool_ok, + 'SCRIPT LOAD': str_if_bytes, + 'SENTINEL GET-MASTER-ADDR-BY-NAME': parse_sentinel_get_master, + 'SENTINEL MASTER': parse_sentinel_master, + 'SENTINEL MASTERS': parse_sentinel_masters, + 'SENTINEL MONITOR': bool_ok, + 'SENTINEL REMOVE': bool_ok, + 'SENTINEL SENTINELS': parse_sentinel_slaves_and_sentinels, + 'SENTINEL SET': bool_ok, + 'SENTINEL SLAVES': parse_sentinel_slaves_and_sentinels, + 'SET': lambda r: r and str_if_bytes(r) == 'OK', + 'SLOWLOG GET': parse_slowlog_get, + 'SLOWLOG LEN': int, + 'SLOWLOG RESET': bool_ok, + 'SSCAN': parse_scan, + 'TIME': lambda x: (int(x[0]), int(x[1])), + 'XCLAIM': parse_xclaim, + 'XGROUP CREATE': bool_ok, + 'XGROUP DELCONSUMER': int, + 'XGROUP DESTROY': bool, + 'XGROUP SETID': bool_ok, + 'XINFO CONSUMERS': parse_list_of_dicts, + 'XINFO GROUPS': parse_list_of_dicts, + 'XINFO STREAM': parse_xinfo_stream, + 'XPENDING': parse_xpending, + 'ZADD': parse_zadd, + 'ZSCAN': parse_zscan, + } @classmethod def from_url(cls, url, db=None, **kwargs): @@ -1233,7 +1209,7 @@ def client_pause(self, timeout): Suspend all the Redis clients for the specified amount of time :param timeout: milliseconds to pause clients """ - if not isinstance(timeout, (int, long)): + if not isinstance(timeout, int): raise DataError("CLIENT PAUSE timeout must be an integer") return self.execute_command('CLIENT PAUSE', str(timeout)) @@ -1688,7 +1664,7 @@ def mset(self, mapping): can be cast to a string via str(). """ items = [] - for pair in iteritems(mapping): + for pair in mapping.items(): items.extend(pair) return self.execute_command('MSET', *items) @@ -1700,7 +1676,7 @@ def msetnx(self, mapping): Returns a boolean indicating if the operation was successful. """ items = [] - for pair in iteritems(mapping): + for pair in mapping.items(): items.extend(pair) return self.execute_command('MSETNX', *items) @@ -2109,7 +2085,7 @@ def sort(self, name, start=None, num=None, by=None, get=None, # Otherwise assume it's an interable and we want to get multiple # values. We can't just iterate blindly because strings are # iterable. - if isinstance(get, (bytes, basestring)): + if isinstance(get, (bytes, str)): pieces.append(b'GET') pieces.append(get) else: @@ -2125,7 +2101,7 @@ def sort(self, name, start=None, num=None, by=None, get=None, pieces.append(store) if groups: - if not get or isinstance(get, (bytes, basestring)) or len(get) < 2: + if not get or isinstance(get, (bytes, str)) or len(get) < 2: raise DataError('when using "groups" the "get" argument ' 'must be specified and contain at least ' 'two keys') @@ -2177,8 +2153,7 @@ def scan_iter(self, match=None, count=None, _type=None): while cursor != 0: cursor, data = self.scan(cursor=cursor, match=match, count=count, _type=_type) - for item in data: - yield item + yield from data def sscan(self, name, cursor=0, match=None, count=None): """ @@ -2209,8 +2184,7 @@ def sscan_iter(self, name, match=None, count=None): while cursor != 0: cursor, data = self.sscan(name, cursor=cursor, match=match, count=count) - for item in data: - yield item + yield from data def hscan(self, name, cursor=0, match=None, count=None): """ @@ -2241,8 +2215,7 @@ def hscan_iter(self, name, match=None, count=None): while cursor != 0: cursor, data = self.hscan(name, cursor=cursor, match=match, count=count) - for item in data.items(): - yield item + yield from data.items() def zscan(self, name, cursor=0, match=None, count=None, score_cast_func=float): @@ -2281,8 +2254,7 @@ def zscan_iter(self, name, match=None, count=None, cursor, data = self.zscan(name, cursor=cursor, match=match, count=count, score_cast_func=score_cast_func) - for item in data: - yield item + yield from data # SET COMMANDS def sadd(self, name, *values): @@ -2386,7 +2358,7 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True): """ pieces = [] if maxlen is not None: - if not isinstance(maxlen, (int, long)) or maxlen < 1: + if not isinstance(maxlen, int) or maxlen < 1: raise DataError('XADD maxlen must be a positive integer') pieces.append(b'MAXLEN') if approximate: @@ -2395,7 +2367,7 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True): pieces.append(id) if not isinstance(fields, dict) or len(fields) == 0: raise DataError('XADD fields must be a non-empty dict') - for pair in iteritems(fields): + for pair in fields.items(): pieces.extend(pair) return self.execute_command('XADD', name, *pieces) @@ -2424,7 +2396,7 @@ def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, justid: optional boolean, false by default. Return just an array of IDs of messages successfully claimed, without returning the actual message """ - if not isinstance(min_idle_time, (int, long)) or min_idle_time < 0: + if not isinstance(min_idle_time, int) or min_idle_time < 0: raise DataError("XCLAIM min_idle_time must be a non negative " "integer") if not isinstance(message_ids, (list, tuple)) or not message_ids: @@ -2436,15 +2408,15 @@ def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, pieces.extend(list(message_ids)) if idle is not None: - if not isinstance(idle, (int, long)): + if not isinstance(idle, int): raise DataError("XCLAIM idle must be an integer") pieces.extend((b'IDLE', str(idle))) if time is not None: - if not isinstance(time, (int, long)): + if not isinstance(time, int): raise DataError("XCLAIM time must be an integer") pieces.extend((b'TIME', str(time))) if retrycount is not None: - if not isinstance(retrycount, (int, long)): + if not isinstance(retrycount, int): raise DataError("XCLAIM retrycount must be an integer") pieces.extend((b'RETRYCOUNT', str(retrycount))) @@ -2560,7 +2532,7 @@ def xpending_range(self, name, groupname, min, max, count, if min is None or max is None or count is None: raise DataError("XPENDING must be provided with min, max " "and count parameters, or none of them. ") - if not isinstance(count, (int, long)) or count < -1: + if not isinstance(count, int) or count < -1: raise DataError("XPENDING count must be a integer >= -1") pieces.extend((min, max, str(count))) if consumername is not None: @@ -2584,7 +2556,7 @@ def xrange(self, name, min='-', max='+', count=None): """ pieces = [min, max] if count is not None: - if not isinstance(count, (int, long)) or count < 1: + if not isinstance(count, int) or count < 1: raise DataError('XRANGE count must be a positive integer') pieces.append(b'COUNT') pieces.append(str(count)) @@ -2602,19 +2574,19 @@ def xread(self, streams, count=None, block=None): """ pieces = [] if block is not None: - if not isinstance(block, (int, long)) or block < 0: + if not isinstance(block, int) or block < 0: raise DataError('XREAD block must be a non-negative integer') pieces.append(b'BLOCK') pieces.append(str(block)) if count is not None: - if not isinstance(count, (int, long)) or count < 1: + if not isinstance(count, int) or count < 1: raise DataError('XREAD count must be a positive integer') pieces.append(b'COUNT') pieces.append(str(count)) if not isinstance(streams, dict) or len(streams) == 0: raise DataError('XREAD streams must be a non empty dict') pieces.append(b'STREAMS') - keys, values = izip(*iteritems(streams)) + keys, values = zip(*streams.items()) pieces.extend(keys) pieces.extend(values) return self.execute_command('XREAD', *pieces) @@ -2634,12 +2606,12 @@ def xreadgroup(self, groupname, consumername, streams, count=None, """ pieces = [b'GROUP', groupname, consumername] if count is not None: - if not isinstance(count, (int, long)) or count < 1: + if not isinstance(count, int) or count < 1: raise DataError("XREADGROUP count must be a positive integer") pieces.append(b'COUNT') pieces.append(str(count)) if block is not None: - if not isinstance(block, (int, long)) or block < 0: + if not isinstance(block, int) or block < 0: raise DataError("XREADGROUP block must be a non-negative " "integer") pieces.append(b'BLOCK') @@ -2666,7 +2638,7 @@ def xrevrange(self, name, max='+', min='-', count=None): """ pieces = [max, min] if count is not None: - if not isinstance(count, (int, long)) or count < 1: + if not isinstance(count, int) or count < 1: raise DataError('XREVRANGE count must be a positive integer') pieces.append(b'COUNT') pieces.append(str(count)) @@ -2729,7 +2701,7 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False): if incr: pieces.append(b'INCR') options['as_score'] = True - for pair in iteritems(mapping): + for pair in mapping.items(): pieces.append(pair[1]) pieces.append(pair[0]) return self.execute_command('ZADD', name, *pieces, **options) @@ -3015,7 +2987,7 @@ def zunionstore(self, dest, keys, aggregate=None): def _zaggregate(self, command, dest, keys, aggregate=None): pieces = [command, dest, len(keys)] if isinstance(keys, dict): - keys, weights = iterkeys(keys), itervalues(keys) + keys, weights = keys.keys(), keys.values() else: weights = None pieces.extend(keys) @@ -3117,7 +3089,7 @@ def hmset(self, name, mapping): if not mapping: raise DataError("'hmset' with 'mapping' of length 0") items = [] - for pair in iteritems(mapping): + for pair in mapping.items(): items.extend(pair) return self.execute_command('HMSET', name, *items) @@ -3379,7 +3351,7 @@ def module_list(self): StrictRedis = Redis -class Monitor(object): +class Monitor: """ Monitor is useful for handling the MONITOR command to the redis server. next_command() method returns one command from monitor @@ -3445,7 +3417,7 @@ def listen(self): yield self.next_command() -class PubSub(object): +class PubSub: """ PubSub provides publish, subscribe and listen support to Redis channels. @@ -3513,12 +3485,12 @@ def on_connect(self, connection): self.pending_unsubscribe_patterns.clear() if self.channels: channels = {} - for k, v in iteritems(self.channels): + for k, v in self.channels.items(): channels[self.encoder.decode(k, force=True)] = v self.subscribe(**channels) if self.patterns: patterns = {} - for k, v in iteritems(self.patterns): + for k, v in self.patterns.items(): patterns[self.encoder.decode(k, force=True)] = v self.psubscribe(**patterns) @@ -3601,7 +3573,7 @@ def _normalize_keys(self, data): """ encode = self.encoder.encode decode = self.encoder.decode - return {decode(encode(k)): v for k, v in iteritems(data)} + return {decode(encode(k)): v for k, v in data.items()} def psubscribe(self, *args, **kwargs): """ @@ -3615,7 +3587,7 @@ def psubscribe(self, *args, **kwargs): args = list_or_args(args[0], args[1:]) new_patterns = dict.fromkeys(args) new_patterns.update(kwargs) - ret_val = self.execute_command('PSUBSCRIBE', *iterkeys(new_patterns)) + ret_val = self.execute_command('PSUBSCRIBE', *new_patterns.keys()) # update the patterns dict AFTER we send the command. we don't want to # subscribe twice to these patterns, once for the command and again # for the reconnection. @@ -3649,7 +3621,7 @@ def subscribe(self, *args, **kwargs): args = list_or_args(args[0], args[1:]) new_channels = dict.fromkeys(args) new_channels.update(kwargs) - ret_val = self.execute_command('SUBSCRIBE', *iterkeys(new_channels)) + ret_val = self.execute_command('SUBSCRIBE', *new_channels.keys()) # update the channels dict AFTER we send the command. we don't want to # subscribe twice to these channels, once for the command and again # for the reconnection. @@ -3704,7 +3676,7 @@ def handle_message(self, response, ignore_subscribe_messages=False): with a message handler, the handler is invoked instead of a parsed message being returned. """ - message_type = nativestr(response[0]) + message_type = str_if_bytes(response[0]) if message_type == 'pmessage': message = { 'type': message_type, @@ -3758,11 +3730,11 @@ def handle_message(self, response, ignore_subscribe_messages=False): return message def run_in_thread(self, sleep_time=0, daemon=False): - for channel, handler in iteritems(self.channels): + for channel, handler in self.channels.items(): if handler is None: raise PubSubError("Channel: '%s' has no handler registered" % channel) - for pattern, handler in iteritems(self.patterns): + for pattern, handler in self.patterns.items(): if handler is None: raise PubSubError("Pattern: '%s' has no handler registered" % pattern) @@ -3774,7 +3746,7 @@ def run_in_thread(self, sleep_time=0, daemon=False): class PubSubWorkerThread(threading.Thread): def __init__(self, pubsub, sleep_time, daemon=False): - super(PubSubWorkerThread, self).__init__() + super().__init__() self.daemon = daemon self.pubsub = pubsub self.sleep_time = sleep_time @@ -3845,12 +3817,8 @@ def __del__(self): def __len__(self): return len(self.command_stack) - def __nonzero__(self): - "Pipeline instances should always evaluate to True on Python 2.7" - return True - def __bool__(self): - "Pipeline instances should always evaluate to True on Python 3+" + "Pipeline instances should always evaluate to True" return True def reset(self): @@ -4007,7 +3975,7 @@ def _execute_transaction(self, connection, commands, raise_on_error): # We have to run response callbacks manually data = [] - for r, cmd in izip(response, commands): + for r, cmd in zip(response, commands): if not isinstance(r, Exception): args, options = cmd command_name = args[0] @@ -4040,9 +4008,9 @@ def raise_first_error(self, commands, response): raise r def annotate_exception(self, exception, number, command): - cmd = ' '.join(imap(safe_unicode, command)) + cmd = ' '.join(map(safe_str, command)) msg = 'Command # %d (%s) of pipeline caused error: %s' % ( - number, cmd, safe_unicode(exception.args[0])) + number, cmd, exception.args[0]) exception.args = (msg,) + exception.args[1:] def parse_response(self, connection, command_name, **options): @@ -4063,7 +4031,7 @@ def load_scripts(self): # get buffered in the pipeline. exists = immediate('SCRIPT EXISTS', *shas) if not all(exists): - for s, exist in izip(scripts, exists): + for s, exist in zip(scripts, exists): if not exist: s.sha = immediate('SCRIPT LOAD', s.script) @@ -4117,7 +4085,7 @@ def unwatch(self): return self.watching and self.execute_command('UNWATCH') or True -class Script(object): +class Script: "An executable Lua script object returned by ``register_script``" def __init__(self, registered_client, script): @@ -4125,7 +4093,7 @@ def __init__(self, registered_client, script): self.script = script # Precalculate and store the SHA1 hex digest of the script. - if isinstance(script, basestring): + if isinstance(script, str): # We need the encoding from the client in order to generate an # accurate byte representation of the script encoder = registered_client.connection_pool.get_encoder() @@ -4151,7 +4119,7 @@ def __call__(self, keys=[], args=[], client=None): return client.evalsha(self.sha, len(keys), *args) -class BitFieldOperation(object): +class BitFieldOperation: """ Command builder for BITFIELD commands. """ diff --git a/redis/connection.py b/redis/connection.py index 22d3902995..a29f9b2fa6 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -1,7 +1,8 @@ -from __future__ import unicode_literals from distutils.version import StrictVersion from itertools import chain from time import time +from queue import LifoQueue, Empty, Full +from urllib.parse import parse_qs, unquote, urlparse import errno import io import os @@ -9,11 +10,6 @@ import threading import warnings -from redis._compat import (xrange, imap, unicode, long, - nativestr, basestring, iteritems, - LifoQueue, Empty, Full, urlparse, parse_qs, - recv, recv_into, unquote, BlockingIOError, - sendall, shutdown, ssl_wrap_socket) from redis.exceptions import ( AuthenticationError, AuthenticationWrongNumberOfArgsError, @@ -31,7 +27,7 @@ TimeoutError, ModuleError, ) -from redis.utils import HIREDIS_AVAILABLE +from redis.utils import HIREDIS_AVAILABLE, str_if_bytes try: import ssl @@ -50,16 +46,6 @@ else: NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLError] = 2 -# In Python 2.7 a socket.error is raised for a nonblocking read. -# The _compat module aliases BlockingIOError to socket.error to be -# Python 2/3 compatible. -# However this means that all socket.error exceptions need to be handled -# properly within these exception handlers. -# We need to make sure socket.error is included in these handlers and -# provide a dummy error number that will never match a real exception. -if socket.error not in NONBLOCKING_EXCEPTION_ERROR_NUMBERS: - NONBLOCKING_EXCEPTION_ERROR_NUMBERS[socket.error] = -999999 - NONBLOCKING_EXCEPTIONS = tuple(NONBLOCKING_EXCEPTION_ERROR_NUMBERS.keys()) if HIREDIS_AVAILABLE: @@ -101,7 +87,7 @@ "types, can't unload" -class Encoder(object): +class Encoder: "Encode strings to bytes-like and decode bytes-like to strings" def __init__(self, encoding, encoding_errors, decode_responses): @@ -117,17 +103,14 @@ def encode(self, value): # special case bool since it is a subclass of int raise DataError("Invalid input of type: 'bool'. Convert to a " "bytes, string, int or float first.") - elif isinstance(value, float): + elif isinstance(value, (int, float)): value = repr(value).encode() - elif isinstance(value, (int, long)): - # python 2 repr() on longs is '123L', so use str() instead - value = str(value).encode() - elif not isinstance(value, basestring): + elif not isinstance(value, str): # a value we don't know how to deal with. throw an error typename = type(value).__name__ raise DataError("Invalid input of type: '%s'. Convert to a " "bytes, string, int or float first." % typename) - if isinstance(value, unicode): + if isinstance(value, str): value = value.encode(self.encoding, self.encoding_errors) return value @@ -141,7 +124,7 @@ def decode(self, value, force=False): return value -class BaseParser(object): +class BaseParser: EXCEPTION_CLASSES = { 'ERR': { 'max number of clients reached': ConnectionError, @@ -180,7 +163,7 @@ def parse_error(self, response): return ResponseError(response) -class SocketBuffer(object): +class SocketBuffer: def __init__(self, socket, socket_read_size, socket_timeout): self._sock = socket self.socket_read_size = socket_read_size @@ -208,7 +191,7 @@ def _read_from_socket(self, length=None, timeout=SENTINEL, if custom_timeout: sock.settimeout(timeout) while True: - data = recv(self._sock, socket_read_size) + data = self._sock.recv(socket_read_size) # an empty string indicates the server shutdown the socket if isinstance(data, bytes) and len(data) == 0: raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) @@ -345,7 +328,7 @@ def read_response(self): # server returned an error if byte == b'-': - response = nativestr(response) + response = response.decode('utf-8', errors='replace') error = self.parse_error(response) # if the error is a ConnectionError, raise immediately so the user # is notified @@ -361,7 +344,7 @@ def read_response(self): pass # int value elif byte == b':': - response = long(response) + response = int(response) # bulk response elif byte == b'$': length = int(response) @@ -373,7 +356,7 @@ def read_response(self): length = int(response) if length == -1: return None - response = [self.read_response() for i in xrange(length)] + response = [self.read_response() for i in range(length)] if isinstance(response, bytes): response = self.encoder.decode(response) return response @@ -437,12 +420,12 @@ def read_from_socket(self, timeout=SENTINEL, raise_on_timeout=True): if custom_timeout: sock.settimeout(timeout) if HIREDIS_USE_BYTE_BUFFER: - bufflen = recv_into(self._sock, self._buffer) + bufflen = self._sock.recv_into(self._buffer) if bufflen == 0: raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) self._reader.feed(self._buffer, 0, bufflen) else: - buffer = recv(self._sock, self.socket_read_size) + buffer = self._sock.recv(self.socket_read_size) # an empty string indicates the server shutdown the socket if not isinstance(buffer, bytes) or len(buffer) == 0: raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) @@ -507,7 +490,7 @@ def read_response(self): DefaultParser = PythonParser -class Connection(object): +class Connection: "Manages TCP communication to and from a Redis server" def __init__(self, host='localhost', port=6379, db=0, password=None, @@ -606,7 +589,7 @@ def _connect(self): # TCP_KEEPALIVE if self.socket_keepalive: sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - for k, v in iteritems(self.socket_keepalive_options): + for k, v in self.socket_keepalive_options.items(): sock.setsockopt(socket.IPPROTO_TCP, k, v) # set the socket_connect_timeout before we connect @@ -619,14 +602,14 @@ def _connect(self): sock.settimeout(self.socket_timeout) return sock - except socket.error as _: + except OSError as _: err = _ if sock is not None: sock.close() if err is not None: raise err - raise socket.error("socket.getaddrinfo returned an empty list") + raise OSError("socket.getaddrinfo returned an empty list") def _error_message(self, exception): # args for socket.error can either be (errno, "message") @@ -662,19 +645,19 @@ def on_connect(self): self.send_command('AUTH', self.password, check_health=False) auth_response = self.read_response() - if nativestr(auth_response) != 'OK': + if str_if_bytes(auth_response) != 'OK': raise AuthenticationError('Invalid Username or Password') # if a client_name is given, set it if self.client_name: self.send_command('CLIENT', 'SETNAME', self.client_name) - if nativestr(self.read_response()) != 'OK': + if str_if_bytes(self.read_response()) != 'OK': raise ConnectionError('Error setting client name') # if a database is specified, switch to it if self.db: self.send_command('SELECT', self.db) - if nativestr(self.read_response()) != 'OK': + if str_if_bytes(self.read_response()) != 'OK': raise ConnectionError('Invalid Database') def disconnect(self): @@ -684,9 +667,9 @@ def disconnect(self): return try: if os.getpid() == self.pid: - shutdown(self._sock, socket.SHUT_RDWR) + self._sock.shutdown(socket.SHUT_RDWR) self._sock.close() - except socket.error: + except OSError: pass self._sock = None @@ -695,13 +678,13 @@ def check_health(self): if self.health_check_interval and time() > self.next_health_check: try: self.send_command('PING', check_health=False) - if nativestr(self.read_response()) != 'PONG': + if str_if_bytes(self.read_response()) != 'PONG': raise ConnectionError( 'Bad response from PING health check') except (ConnectionError, TimeoutError): self.disconnect() self.send_command('PING', check_health=False) - if nativestr(self.read_response()) != 'PONG': + if str_if_bytes(self.read_response()) != 'PONG': raise ConnectionError( 'Bad response from PING health check') @@ -716,7 +699,7 @@ def send_packed_command(self, command, check_health=True): if isinstance(command, str): command = [command] for item in command: - sendall(self._sock, item) + self._sock.sendall(item) except socket.timeout: self.disconnect() raise TimeoutError("Timeout writing to socket") @@ -777,7 +760,7 @@ def pack_command(self, *args): # arguments to be sent separately, so split the first argument # manually. These arguments should be bytestrings so that they are # not encoded. - if isinstance(args[0], unicode): + if isinstance(args[0], str): args = tuple(args[0].encode().split()) + args[1:] elif b' ' in args[0]: args = tuple(args[0].split()) + args[1:] @@ -785,7 +768,7 @@ def pack_command(self, *args): buff = SYM_EMPTY.join((SYM_STAR, str(len(args)).encode(), SYM_CRLF)) buffer_cutoff = self._buffer_cutoff - for arg in imap(self.encoder.encode, args): + for arg in map(self.encoder.encode, args): # to avoid large string mallocs, chunk the command into the # output list if we're sending large values or memoryviews arg_length = len(arg) @@ -838,13 +821,13 @@ def __init__(self, ssl_keyfile=None, ssl_certfile=None, if not ssl_available: raise RedisError("Python wasn't built with SSL support") - super(SSLConnection, self).__init__(**kwargs) + super().__init__(**kwargs) self.keyfile = ssl_keyfile self.certfile = ssl_certfile if ssl_cert_reqs is None: ssl_cert_reqs = ssl.CERT_NONE - elif isinstance(ssl_cert_reqs, basestring): + elif isinstance(ssl_cert_reqs, str): CERT_REQS = { 'none': ssl.CERT_NONE, 'optional': ssl.CERT_OPTIONAL, @@ -861,27 +844,16 @@ def __init__(self, ssl_keyfile=None, ssl_certfile=None, def _connect(self): "Wrap the socket with SSL support" - sock = super(SSLConnection, self)._connect() - if hasattr(ssl, "create_default_context"): - context = ssl.create_default_context() - context.check_hostname = self.check_hostname - context.verify_mode = self.cert_reqs - if self.certfile and self.keyfile: - context.load_cert_chain(certfile=self.certfile, - keyfile=self.keyfile) - if self.ca_certs: - context.load_verify_locations(self.ca_certs) - sock = ssl_wrap_socket(context, sock, server_hostname=self.host) - else: - # In case this code runs in a version which is older than 2.7.9, - # we want to fall back to old code - sock = ssl_wrap_socket(ssl, - sock, - cert_reqs=self.cert_reqs, - keyfile=self.keyfile, - certfile=self.certfile, - ca_certs=self.ca_certs) - return sock + sock = super()._connect() + context = ssl.create_default_context() + context.check_hostname = self.check_hostname + context.verify_mode = self.cert_reqs + if self.certfile and self.keyfile: + context.load_cert_chain(certfile=self.certfile, + keyfile=self.keyfile) + if self.ca_certs: + context.load_verify_locations(self.ca_certs) + return context.wrap_socket(sock, server_hostname=self.host) class UnixDomainSocketConnection(Connection): @@ -941,7 +913,7 @@ def _error_message(self, exception): def to_bool(value): if value is None or value == '': return None - if isinstance(value, basestring) and value.upper() in FALSE_STRINGS: + if isinstance(value, str) and value.upper() in FALSE_STRINGS: return False return bool(value) @@ -957,7 +929,7 @@ def to_bool(value): } -class ConnectionPool(object): +class ConnectionPool: """ Create a connection pool. ``If max_connections`` is set, then this object raises :py:class:`~redis.ConnectionError` when the pool's @@ -1019,7 +991,7 @@ def from_url(cls, url, db=None, decode_components=False, **kwargs): url = urlparse(url) url_options = {} - for name, value in iteritems(parse_qs(url.query)): + for name, value in parse_qs(url.query).items(): if value and len(value) > 0: parser = URL_QUERY_ARGUMENT_PARSERS.get(name) if parser: @@ -1096,7 +1068,7 @@ def from_url(cls, url, db=None, decode_components=False, **kwargs): def __init__(self, connection_class=Connection, max_connections=None, **connection_kwargs): max_connections = max_connections or 2 ** 31 - if not isinstance(max_connections, (int, long)) or max_connections < 0: + if not isinstance(max_connections, int) or max_connections < 0: raise ValueError('"max_connections" must be a positive integer') self.connection_class = connection_class @@ -1173,14 +1145,7 @@ def _checkpid(self): # that time it is assumed that the child is deadlocked and a # redis.ChildDeadlockedError error is raised. if self.pid != os.getpid(): - # python 2.7 doesn't support a timeout option to lock.acquire() - # we have to mimic lock timeouts ourselves. - timeout_at = time() + 5 - acquired = False - while time() < timeout_at: - acquired = self._fork_lock.acquire(False) - if acquired: - break + acquired = self._fork_lock.acquire(timeout=5) if not acquired: raise ChildDeadlockedError # reset() the instance for the new process if another thread @@ -1323,7 +1288,7 @@ def __init__(self, max_connections=50, timeout=20, self.queue_class = queue_class self.timeout = timeout - super(BlockingConnectionPool, self).__init__( + super().__init__( connection_class=connection_class, max_connections=max_connections, **connection_kwargs) diff --git a/redis/lock.py b/redis/lock.py index 5c4774833f..e51f169695 100644 --- a/redis/lock.py +++ b/redis/lock.py @@ -1,11 +1,11 @@ import threading import time as mod_time import uuid +from types import SimpleNamespace from redis.exceptions import LockError, LockNotOwnedError -from redis.utils import dummy -class Lock(object): +class Lock: """ A shared, distributed Lock. Using Redis for locking allows the Lock to be shared across processes and/or machines. @@ -129,7 +129,11 @@ def __init__(self, redis, name, timeout=None, sleep=0.1, self.blocking = blocking self.blocking_timeout = blocking_timeout self.thread_local = bool(thread_local) - self.local = threading.local() if self.thread_local else dummy() + self.local = ( + threading.local() + if self.thread_local + else SimpleNamespace() + ) self.local.token = None self.register_scripts() diff --git a/redis/sentinel.py b/redis/sentinel.py index 203c859bcc..b9d77f1c6e 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -5,7 +5,7 @@ from redis.connection import ConnectionPool, Connection from redis.exceptions import (ConnectionError, ResponseError, ReadOnlyError, TimeoutError) -from redis._compat import iteritems, nativestr, xrange +from redis.utils import str_if_bytes class MasterNotFoundError(ConnectionError): @@ -19,7 +19,7 @@ class SlaveNotFoundError(ConnectionError): class SentinelManagedConnection(Connection): def __init__(self, **kwargs): self.connection_pool = kwargs.pop('connection_pool') - super(SentinelManagedConnection, self).__init__(**kwargs) + super().__init__(**kwargs) def __repr__(self): pool = self.connection_pool @@ -31,10 +31,10 @@ def __repr__(self): def connect_to(self, address): self.host, self.port = address - super(SentinelManagedConnection, self).connect() + super().connect() if self.connection_pool.check_connection: self.send_command('PING') - if nativestr(self.read_response()) != 'PONG': + if str_if_bytes(self.read_response()) != 'PONG': raise ConnectionError('PING failed') def connect(self): @@ -52,7 +52,7 @@ def connect(self): def read_response(self): try: - return super(SentinelManagedConnection, self).read_response() + return super().read_response() except ReadOnlyError: if self.connection_pool.is_master: # When talking to a master, a ReadOnlyError when likely @@ -78,7 +78,7 @@ def __init__(self, service_name, sentinel_manager, **kwargs): 'connection_class', SentinelManagedConnection) self.is_master = kwargs.pop('is_master', True) self.check_connection = kwargs.pop('check_connection', False) - super(SentinelConnectionPool, self).__init__(**kwargs) + super().__init__(**kwargs) self.connection_kwargs['connection_pool'] = weakref.proxy(self) self.service_name = service_name self.sentinel_manager = sentinel_manager @@ -91,7 +91,7 @@ def __repr__(self): ) def reset(self): - super(SentinelConnectionPool, self).reset() + super().reset() self.master_address = None self.slave_rr_counter = None @@ -119,7 +119,7 @@ def rotate_slaves(self): if slaves: if self.slave_rr_counter is None: self.slave_rr_counter = random.randint(0, len(slaves) - 1) - for _ in xrange(len(slaves)): + for _ in range(len(slaves)): self.slave_rr_counter = ( self.slave_rr_counter + 1) % len(slaves) slave = slaves[self.slave_rr_counter] @@ -132,7 +132,7 @@ def rotate_slaves(self): raise SlaveNotFoundError('No slave found for %r' % (self.service_name)) -class Sentinel(object): +class Sentinel: """ Redis Sentinel cluster client @@ -168,7 +168,7 @@ def __init__(self, sentinels, min_other_sentinels=0, sentinel_kwargs=None, if sentinel_kwargs is None: sentinel_kwargs = { k: v - for k, v in iteritems(connection_kwargs) + for k, v in connection_kwargs.items() if k.startswith('socket_') } self.sentinel_kwargs = sentinel_kwargs diff --git a/redis/utils.py b/redis/utils.py index 6ef6fd4ad2..3664708f8d 100644 --- a/redis/utils.py +++ b/redis/utils.py @@ -26,8 +26,13 @@ def pipeline(redis_obj): p.execute() -class dummy(object): - """ - Instances of this class can be used as an attribute container. - """ - pass +def str_if_bytes(value): + return ( + value.decode('utf-8', errors='replace') + if isinstance(value, bytes) + else value + ) + + +def safe_str(value): + return str(str_if_bytes(value)) diff --git a/setup.cfg b/setup.cfg index 430cba081e..3fdea4e52d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,9 +17,8 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 @@ -30,13 +29,10 @@ classifiers = [options] packages = redis -python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* +python_requires = >=3.5 [options.extras_require] hiredis = hiredis>=0.1.3 [flake8] exclude = .venv,.tox,dist,docs,build,*.egg,redis_install,env,venv,.undodir - -[bdist_wheel] -universal = 1 diff --git a/tests/conftest.py b/tests/conftest.py index caca8cc51d..26893dbd6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,9 @@ -import random - import pytest +import random import redis -from mock import Mock - -from redis._compat import urlparse from distutils.version import StrictVersion +from unittest.mock import Mock +from urllib.parse import urlparse # redis 6 release candidates report a version number of 5.9.x. Use this diff --git a/tests/test_commands.py b/tests/test_commands.py index 91bcbb3098..38e2d1a361 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,13 +1,11 @@ -from __future__ import unicode_literals import binascii import datetime import pytest import re import redis import time +from string import ascii_letters -from redis._compat import (unichr, ascii_letters, iteritems, iterkeys, - itervalues, long, basestring) from redis.client import parse_info from redis import exceptions @@ -44,7 +42,7 @@ def get_stream_message(client, stream, message_id): # RESPONSE CALLBACKS -class TestResponseCallbacks(object): +class TestResponseCallbacks: "Tests for the response callback system" def test_response_callbacks(self, r): @@ -58,7 +56,7 @@ def test_case_insensitive_command_names(self, r): assert r.response_callbacks['del'] == r.response_callbacks['DEL'] -class TestRedisCommands(object): +class TestRedisCommands: def test_command_on_invalid_key_type(self, r): r.lpush('a', '1') with pytest.raises(redis.ResponseError): @@ -93,7 +91,7 @@ def teardown(): @skip_if_server_version_lt(REDIS_6_VERSION) def test_acl_genpass(self, r): password = r.acl_genpass() - assert isinstance(password, basestring) + assert isinstance(password, str) @skip_if_server_version_lt(REDIS_6_VERSION) def test_acl_getuser_setuser(self, r, request): @@ -237,7 +235,7 @@ def test_acl_users(self, r): @skip_if_server_version_lt(REDIS_6_VERSION) def test_acl_whoami(self, r): username = r.acl_whoami() - assert isinstance(username, basestring) + assert isinstance(username, str) def test_client_list(self, r): clients = r.client_list() @@ -411,7 +409,7 @@ def test_ping(self, r): def test_slowlog_get(self, r, slowlog): assert r.slowlog_reset() - unicode_string = unichr(3456) + 'abcd' + unichr(3421) + unicode_string = chr(3456) + 'abcd' + chr(3421) r.get(unicode_string) slowlog = r.slowlog_get() assert isinstance(slowlog, list) @@ -646,7 +644,7 @@ def test_get_and_set(self, r): assert r.get('a') is None byte_string = b'value' integer = 5 - unicode_string = unichr(3456) + 'abcd' + unichr(3421) + unicode_string = chr(3456) + 'abcd' + chr(3421) assert r.set('byte_string', byte_string) assert r.set('integer', 5) assert r.set('unicode_string', unicode_string) @@ -733,7 +731,7 @@ def test_mget(self, r): def test_mset(self, r): d = {'a': b'1', 'b': b'2', 'c': b'3'} assert r.mset(d) - for k, v in iteritems(d): + for k, v in d.items(): assert r[k] == v def test_msetnx(self, r): @@ -741,7 +739,7 @@ def test_msetnx(self, r): assert r.msetnx(d) d2 = {'a': b'x', 'd': b'4'} assert not r.msetnx(d2) - for k, v in iteritems(d): + for k, v in d.items(): assert r[k] == v assert r.get('d') is None @@ -1692,7 +1690,7 @@ def test_hincrbyfloat(self, r): def test_hkeys(self, r): h = {b'a1': b'1', b'a2': b'2', b'a3': b'3'} r.hset('a', mapping=h) - local_keys = list(iterkeys(h)) + local_keys = list(h.keys()) remote_keys = r.hkeys('a') assert (sorted(local_keys) == sorted(remote_keys)) @@ -1722,7 +1720,7 @@ def test_hsetnx(self, r): def test_hvals(self, r): h = {b'a1': b'1', b'a2': b'2', b'a3': b'3'} r.hset('a', mapping=h) - local_vals = list(itervalues(h)) + local_vals = list(h.values()) remote_vals = r.hvals('a') assert sorted(local_vals) == sorted(remote_vals) @@ -2320,8 +2318,8 @@ def test_xinfo_consumers(self, r): ] # we can't determine the idle time, so just make sure it's an int - assert isinstance(info[0].pop('idle'), (int, long)) - assert isinstance(info[1].pop('idle'), (int, long)) + assert isinstance(info[0].pop('idle'), int) + assert isinstance(info[1].pop('idle'), int) assert info == expected @skip_if_server_version_lt('5.0.0') @@ -2650,7 +2648,7 @@ def test_memory_stats(self, r): r.set('foo', 'bar') stats = r.memory_stats() assert isinstance(stats, dict) - for key, value in iteritems(stats): + for key, value in stats.items(): if key.startswith('db.'): assert isinstance(value, dict) @@ -2665,7 +2663,7 @@ def test_module_list(self, r): assert not r.module_list() -class TestBinarySave(object): +class TestBinarySave: def test_binary_get_set(self, r): assert r.set(' foo bar ', '123') @@ -2691,14 +2689,14 @@ def test_binary_lists(self, r): b'foo\tbar\x07': [b'7', b'8', b'9'], } # fill in lists - for key, value in iteritems(mapping): + for key, value in mapping.items(): r.rpush(key, *value) # check that KEYS returns all the keys as they are - assert sorted(r.keys('*')) == sorted(iterkeys(mapping)) + assert sorted(r.keys('*')) == sorted(mapping.keys()) # check that it is possible to get list content by key name - for key, value in iteritems(mapping): + for key, value in mapping.items(): assert r.lrange(key, 0, -1) == value def test_22_info(self, r): diff --git a/tests/test_connection.py b/tests/test_connection.py index 5ca92542cf..128bac7d96 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,4 +1,4 @@ -import mock +from unittest import mock import pytest from redis.exceptions import InvalidResponse diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index c49ecda8b6..c26090b02b 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -1,9 +1,9 @@ import os -import mock import pytest import re import redis import time +from unittest import mock from threading import Thread from redis.connection import ssl_available, to_bool @@ -11,7 +11,7 @@ from .test_pubsub import wait_for_message -class DummyConnection(object): +class DummyConnection: description_format = "DummyConnection<>" def __init__(self, **kwargs): @@ -25,7 +25,7 @@ def can_read(self): return False -class TestConnectionPool(object): +class TestConnectionPool: def get_pool(self, connection_kwargs=None, max_connections=None, connection_class=redis.Connection): connection_kwargs = connection_kwargs or {} @@ -93,7 +93,7 @@ def test_repr_contains_db_info_unix(self): assert repr(pool) == expected -class TestBlockingConnectionPool(object): +class TestBlockingConnectionPool: def get_pool(self, connection_kwargs=None, max_connections=10, timeout=20): connection_kwargs = connection_kwargs or {} pool = redis.BlockingConnectionPool(connection_class=DummyConnection, @@ -179,7 +179,7 @@ def test_repr_contains_db_info_unix(self): assert repr(pool) == expected -class TestConnectionPoolURLParsing(object): +class TestConnectionPoolURLParsing: def test_defaults(self): pool = redis.ConnectionPool.from_url('redis://localhost') assert pool.connection_class == redis.Connection @@ -411,7 +411,7 @@ def test_invalid_scheme_raises_error(self): ) -class TestConnectionPoolUnixSocketURLParsing(object): +class TestConnectionPoolUnixSocketURLParsing: def test_defaults(self): pool = redis.ConnectionPool.from_url('unix:///socket') assert pool.connection_class == redis.UnixDomainSocketConnection @@ -519,7 +519,7 @@ def test_extra_querystring_options(self): } -class TestSSLConnectionURLParsing(object): +class TestSSLConnectionURLParsing: @pytest.mark.skipif(not ssl_available, reason="SSL not installed") def test_defaults(self): pool = redis.ConnectionPool.from_url('rediss://localhost') @@ -561,7 +561,7 @@ def get_connection(self, *args, **kwargs): assert pool.get_connection('_').check_hostname is True -class TestConnection(object): +class TestConnection: def test_on_connect_error(self): """ An error in Connection.on_connect should disconnect from the server @@ -658,7 +658,7 @@ def test_connect_invalid_password_supplied(self, r): r.execute_command('DEBUG', 'ERROR', 'ERR invalid password') -class TestMultiConnectionClient(object): +class TestMultiConnectionClient: @pytest.fixture() def r(self, request): return _get_client(redis.Redis, @@ -671,7 +671,7 @@ def test_multi_connection_command(self, r): assert r.get('a') == b'123' -class TestHealthCheck(object): +class TestHealthCheck: interval = 60 @pytest.fixture() diff --git a/tests/test_encoding.py b/tests/test_encoding.py index ea7db7fc6e..706654f89f 100644 --- a/tests/test_encoding.py +++ b/tests/test_encoding.py @@ -1,13 +1,11 @@ -from __future__ import unicode_literals import pytest import redis -from redis._compat import unichr, unicode from redis.connection import Connection from .conftest import _get_client -class TestEncoding(object): +class TestEncoding: @pytest.fixture() def r(self, request): return _get_client(redis.Redis, request=request, decode_responses=True) @@ -21,21 +19,21 @@ def r_no_decode(self, request): ) def test_simple_encoding(self, r_no_decode): - unicode_string = unichr(3456) + 'abcd' + unichr(3421) + unicode_string = chr(3456) + 'abcd' + chr(3421) r_no_decode['unicode-string'] = unicode_string.encode('utf-8') cached_val = r_no_decode['unicode-string'] assert isinstance(cached_val, bytes) assert unicode_string == cached_val.decode('utf-8') def test_simple_encoding_and_decoding(self, r): - unicode_string = unichr(3456) + 'abcd' + unichr(3421) + unicode_string = chr(3456) + 'abcd' + chr(3421) r['unicode-string'] = unicode_string cached_val = r['unicode-string'] - assert isinstance(cached_val, unicode) + assert isinstance(cached_val, str) assert unicode_string == cached_val def test_memoryview_encoding(self, r_no_decode): - unicode_string = unichr(3456) + 'abcd' + unichr(3421) + unicode_string = chr(3456) + 'abcd' + chr(3421) unicode_string_view = memoryview(unicode_string.encode('utf-8')) r_no_decode['unicode-string-memoryview'] = unicode_string_view cached_val = r_no_decode['unicode-string-memoryview'] @@ -44,21 +42,21 @@ def test_memoryview_encoding(self, r_no_decode): assert unicode_string == cached_val.decode('utf-8') def test_memoryview_encoding_and_decoding(self, r): - unicode_string = unichr(3456) + 'abcd' + unichr(3421) + unicode_string = chr(3456) + 'abcd' + chr(3421) unicode_string_view = memoryview(unicode_string.encode('utf-8')) r['unicode-string-memoryview'] = unicode_string_view cached_val = r['unicode-string-memoryview'] - assert isinstance(cached_val, unicode) + assert isinstance(cached_val, str) assert unicode_string == cached_val def test_list_encoding(self, r): - unicode_string = unichr(3456) + 'abcd' + unichr(3421) + unicode_string = chr(3456) + 'abcd' + chr(3421) result = [unicode_string, unicode_string, unicode_string] r.rpush('a', *result) assert r.lrange('a', 0, -1) == result -class TestEncodingErrors(object): +class TestEncodingErrors: def test_ignore(self, request): r = _get_client(redis.Redis, request=request, decode_responses=True, encoding_errors='ignore') @@ -72,7 +70,7 @@ def test_replace(self, request): assert r.get('a') == 'foo\ufffd' -class TestMemoryviewsAreNotPacked(object): +class TestMemoryviewsAreNotPacked: def test_memoryviews_are_not_packed(self): c = Connection() arg = memoryview(b'some_arg') @@ -84,7 +82,7 @@ def test_memoryviews_are_not_packed(self): assert cmds[3] is arg -class TestCommandsAreNotEncoded(object): +class TestCommandsAreNotEncoded: @pytest.fixture() def r(self, request): return _get_client(redis.Redis, request=request, encoding='utf-16') @@ -93,7 +91,7 @@ def test_basic_command(self, r): r.set('hello', 'world') -class TestInvalidUserInput(object): +class TestInvalidUserInput: def test_boolean_fails(self, r): with pytest.raises(redis.DataError): r.set('a', True) @@ -103,12 +101,9 @@ def test_none_fails(self, r): r.set('a', None) def test_user_type_fails(self, r): - class Foo(object): + class Foo: def __str__(self): return 'Foo' - def __unicode__(self): - return 'Foo' - with pytest.raises(redis.DataError): r.set('a', Foo()) diff --git a/tests/test_lock.py b/tests/test_lock.py index e8bd874253..8bb3c7e80a 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -7,7 +7,7 @@ from .conftest import _get_client -class TestLock(object): +class TestLock: @pytest.fixture() def r_decoded(self, request): return _get_client(Redis, request=request, decode_responses=True) @@ -220,9 +220,9 @@ def test_reacquiring_lock_no_longer_owned_raises_error(self, r): lock.reacquire() -class TestLockClassSelection(object): +class TestLockClassSelection: def test_lock_class_argument(self, r): - class MyLock(object): + class MyLock: def __init__(self, *args, **kwargs): pass diff --git a/tests/test_monitor.py b/tests/test_monitor.py index ee5dc6e39c..1013202f22 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -1,9 +1,7 @@ -from __future__ import unicode_literals -from redis._compat import unicode from .conftest import wait_for_command -class TestMonitor(object): +class TestMonitor: def test_wait_command_not_found(self, r): "Make sure the wait_for_command func works when command is not found" with r.monitor() as m: @@ -17,8 +15,8 @@ def test_response_values(self, r): assert isinstance(response['time'], float) assert response['db'] == 9 assert response['client_type'] in ('tcp', 'unix') - assert isinstance(response['client_address'], unicode) - assert isinstance(response['client_port'], unicode) + assert isinstance(response['client_address'], str) + assert isinstance(response['client_port'], str) assert response['command'] == 'PING' def test_command_with_quoted_key(self, r): diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index 235e3cee0f..2d27c4e8bb 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -17,7 +17,7 @@ def exit_callback(callback, *args): callback(*args) -class TestMultiprocessing(object): +class TestMultiprocessing: # Test connection sharing between forks. # See issue #1085 for details. diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 4f221530ec..9bc4a9f4d9 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,12 +1,10 @@ -from __future__ import unicode_literals import pytest import redis -from redis._compat import unichr, unicode from .conftest import wait_for_command -class TestPipeline(object): +class TestPipeline: def test_pipeline_is_true(self, r): "Ensure pipeline instances are not false-y" with r.pipeline() as pipe: @@ -124,8 +122,8 @@ def test_exec_error_raised(self, r): pipe.set('a', 1).set('b', 2).lpush('c', 3).set('d', 4) with pytest.raises(redis.ResponseError) as ex: pipe.execute() - assert unicode(ex.value).startswith('Command # 3 (LPUSH c 3) of ' - 'pipeline caused error: ') + assert str(ex.value).startswith('Command # 3 (LPUSH c 3) of ' + 'pipeline caused error: ') # make sure the pipe was restored to a working state assert pipe.set('z', 'zzz').execute() == [True] @@ -166,8 +164,8 @@ def test_parse_error_raised(self, r): with pytest.raises(redis.ResponseError) as ex: pipe.execute() - assert unicode(ex.value).startswith('Command # 2 (ZREM b) of ' - 'pipeline caused error: ') + assert str(ex.value).startswith('Command # 2 (ZREM b) of ' + 'pipeline caused error: ') # make sure the pipe was restored to a working state assert pipe.set('z', 'zzz').execute() == [True] @@ -181,8 +179,8 @@ def test_parse_error_raised_transaction(self, r): with pytest.raises(redis.ResponseError) as ex: pipe.execute() - assert unicode(ex.value).startswith('Command # 2 (ZREM b) of ' - 'pipeline caused error: ') + assert str(ex.value).startswith('Command # 2 (ZREM b) of ' + 'pipeline caused error: ') # make sure the pipe was restored to a working state assert pipe.set('z', 'zzz').execute() == [True] @@ -319,13 +317,13 @@ def test_exec_error_in_no_transaction_pipeline(self, r): with pytest.raises(redis.ResponseError) as ex: pipe.execute() - assert unicode(ex.value).startswith('Command # 1 (LLEN a) of ' - 'pipeline caused error: ') + assert str(ex.value).startswith('Command # 1 (LLEN a) of ' + 'pipeline caused error: ') assert r['a'] == b'1' def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r): - key = unichr(3456) + 'abcd' + unichr(3421) + key = chr(3456) + 'abcd' + chr(3421) r[key] = 1 with r.pipeline(transaction=False) as pipe: pipe.llen(key) @@ -334,9 +332,8 @@ def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r): with pytest.raises(redis.ResponseError) as ex: pipe.execute() - expected = unicode('Command # 1 (LLEN %s) of pipeline caused ' - 'error: ') % key - assert unicode(ex.value).startswith(expected) + expected = 'Command # 1 (LLEN %s) of pipeline caused error: ' % key + assert str(ex.value).startswith(expected) assert r[key] == b'1' diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index bf134831c4..ab9f09c84e 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -1,10 +1,8 @@ -from __future__ import unicode_literals import pytest import time import redis from redis.exceptions import ConnectionError -from redis._compat import basestring, unichr from .conftest import _get_client from .conftest import skip_if_server_version_lt @@ -28,7 +26,7 @@ def make_message(type, channel, data, pattern=None): 'type': type, 'pattern': pattern and pattern.encode('utf-8') or None, 'channel': channel and channel.encode('utf-8') or None, - 'data': data.encode('utf-8') if isinstance(data, basestring) else data + 'data': data.encode('utf-8') if isinstance(data, str) else data } @@ -40,7 +38,7 @@ def make_subscribe_test_data(pubsub, type): 'unsub_type': 'unsubscribe', 'sub_func': pubsub.subscribe, 'unsub_func': pubsub.unsubscribe, - 'keys': ['foo', 'bar', 'uni' + unichr(4456) + 'code'] + 'keys': ['foo', 'bar', 'uni' + chr(4456) + 'code'] } elif type == 'pattern': return { @@ -49,12 +47,12 @@ def make_subscribe_test_data(pubsub, type): 'unsub_type': 'punsubscribe', 'sub_func': pubsub.psubscribe, 'unsub_func': pubsub.punsubscribe, - 'keys': ['f*', 'b*', 'uni' + unichr(4456) + '*'] + 'keys': ['f*', 'b*', 'uni' + chr(4456) + '*'] } assert False, 'invalid subscribe type: %s' % type -class TestPubSubSubscribeUnsubscribe(object): +class TestPubSubSubscribeUnsubscribe: def _test_subscribe_unsubscribe(self, p, sub_type, unsub_type, sub_func, unsub_func, keys): @@ -255,7 +253,7 @@ def _test_sub_unsub_all_resub(self, p, sub_type, unsub_type, sub_func, assert p.subscribed is True -class TestPubSubMessages(object): +class TestPubSubMessages: def setup_method(self, method): self.message = None @@ -314,7 +312,7 @@ def test_pattern_message_handler(self, r): def test_unicode_channel_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) - channel = 'uni' + unichr(4456) + 'code' + channel = 'uni' + chr(4456) + 'code' channels = {channel: self.message_handler} p.subscribe(**channels) assert wait_for_message(p) is None @@ -324,8 +322,8 @@ def test_unicode_channel_message_handler(self, r): def test_unicode_pattern_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) - pattern = 'uni' + unichr(4456) + '*' - channel = 'uni' + unichr(4456) + 'code' + pattern = 'uni' + chr(4456) + '*' + channel = 'uni' + chr(4456) + 'code' p.psubscribe(**{pattern: self.message_handler}) assert wait_for_message(p) is None assert r.publish(channel, 'test message') == 1 @@ -342,12 +340,12 @@ def test_get_message_without_subscribe(self, r): assert expect in info.exconly() -class TestPubSubAutoDecoding(object): +class TestPubSubAutoDecoding: "These tests only validate that we get unicode values back" - channel = 'uni' + unichr(4456) + 'code' - pattern = 'uni' + unichr(4456) + '*' - data = 'abc' + unichr(4458) + '123' + channel = 'uni' + chr(4456) + 'code' + pattern = 'uni' + chr(4456) + '*' + data = 'abc' + chr(4458) + '123' def make_message(self, type, channel, data, pattern=None): return { @@ -458,7 +456,7 @@ def test_context_manager(self, r): assert pubsub.patterns == {} -class TestPubSubRedisDown(object): +class TestPubSubRedisDown: def test_channel_subscribe(self, r): r = redis.Redis(host='localhost', port=6390) @@ -467,7 +465,7 @@ def test_channel_subscribe(self, r): p.subscribe('foo') -class TestPubSubSubcommands(object): +class TestPubSubSubcommands: @skip_if_server_version_lt('2.8.0') def test_pubsub_channels(self, r): @@ -504,7 +502,7 @@ def test_pubsub_numpat(self, r): assert r.pubsub_numpat() == 3 -class TestPubSubPings(object): +class TestPubSubPings: @skip_if_server_version_lt('3.0.0') def test_send_pubsub_ping(self, r): @@ -525,7 +523,7 @@ def test_send_pubsub_ping_message(self, r): pattern=None) -class TestPubSubConnectionKilled(object): +class TestPubSubConnectionKilled: @skip_if_server_version_lt('3.0.0') def test_connection_error_raised_when_connection_dies(self, r): @@ -539,7 +537,7 @@ def test_connection_error_raised_when_connection_dies(self, r): wait_for_message(p) -class TestPubSubTimeouts(object): +class TestPubSubTimeouts: def test_get_message_with_timeout_returns_none(self, r): p = r.pubsub() p.subscribe('foo') diff --git a/tests/test_scripting.py b/tests/test_scripting.py index b3d52a58fe..02c0f171d1 100644 --- a/tests/test_scripting.py +++ b/tests/test_scripting.py @@ -1,4 +1,3 @@ -from __future__ import unicode_literals import pytest from redis import exceptions @@ -21,7 +20,7 @@ """ -class TestScripting(object): +class TestScripting: @pytest.fixture(autouse=True) def reset_scripts(self, r): r.script_flush() diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py index c247c723bf..64a7c47d3a 100644 --- a/tests/test_sentinel.py +++ b/tests/test_sentinel.py @@ -5,7 +5,6 @@ from redis import exceptions from redis.sentinel import (Sentinel, SentinelConnectionPool, MasterNotFoundError, SlaveNotFoundError) -from redis._compat import next import redis.sentinel @@ -14,7 +13,7 @@ def master_ip(master_host): yield socket.gethostbyname(master_host) -class SentinelTestClient(object): +class SentinelTestClient: def __init__(self, cluster, id): self.cluster = cluster self.id = id @@ -32,7 +31,7 @@ def sentinel_slaves(self, master_name): return self.cluster.slaves -class SentinelTestCluster(object): +class SentinelTestCluster: def __init__(self, service_name='mymaster', ip='127.0.0.1', port=6379): self.clients = {} self.master = { diff --git a/tox.ini b/tox.ini index d1d97f4906..c783bf06b8 100644 --- a/tox.ini +++ b/tox.ini @@ -3,12 +3,11 @@ addopts = -s [tox] minversion = 2.4 -envlist = {py27,py35,py36,py37,py38,pypy,pypy3}-{plain,hiredis}, flake8, covreport, codecov +envlist = {py35,py36,py37,py38,pypy3}-{plain,hiredis}, flake8, covreport, codecov [testenv] deps = coverage - mock pytest >= 2.7.0 extras = hiredis: hiredis From abe0e969e21a0e1fc18a0e74387ecac5dddeb2cc Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 6 Aug 2020 15:23:30 -0700 Subject: [PATCH 0082/1164] added codecov.yml --- codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..97da41bebb --- /dev/null +++ b/codecov.yml @@ -0,0 +1,4 @@ +coverage: + precision: 2 + round: down + range: "80...100" From bafd98f16d30657e240cd9c58129565dfa13eeb9 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Thu, 6 Aug 2020 15:28:32 -0700 Subject: [PATCH 0083/1164] codecov config --- codecov.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codecov.yml b/codecov.yml index 97da41bebb..05761cebb8 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,6 @@ +codecov: + require_ci_to_pass: yes + coverage: precision: 2 round: down From c73e07c883452d32ec273d04942e017814b04e54 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Sat, 8 Aug 2020 17:00:18 -0700 Subject: [PATCH 0084/1164] turn off the codecov/patch status --- codecov.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codecov.yml b/codecov.yml index 05761cebb8..48cdae1902 100644 --- a/codecov.yml +++ b/codecov.yml @@ -5,3 +5,5 @@ coverage: precision: 2 round: down range: "80...100" + status: + patch: off # off for now as it yells about everything From 6b37f4bedc349eb2c91e680d2ef811a3c2f7e879 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Sat, 15 Aug 2020 16:16:28 -0700 Subject: [PATCH 0085/1164] All values within Redis URLs are url-unquoted via default. Prior versions of redis-py supported this by specifying the ``decode_components`` flag to the ``from_url`` functions. This is now done by default and cannot be disabled. Fixes #589 --- CHANGES | 6 +- redis/client.py | 43 ++++---- redis/connection.py | 185 +++++++++++++++------------------- tests/test_connection_pool.py | 130 +++--------------------- 4 files changed, 126 insertions(+), 238 deletions(-) diff --git a/CHANGES b/CHANGES index b164991b8f..2b7dbb8d1f 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,9 @@ * (in development) - * Removed support for end of life Python 2.7. + * BACKWARDS INCOMPATIBLE: Removed support for end of life Python 2.7. #1318 + * BACKWARDS INCOMPATIBLE: All values within Redis URLs are unquoted via + urllib.parse.unquote. Prior versions of redis-py supported this by + specifying the ``decode_components`` flag to the ``from_url`` functions. + This is now done by default and cannot be disabled. #589 * Provide a development and testing environment via docker. Thanks @abrookins. #1365 * Added support for the LPOS command available in Redis 6.0.6. Thanks diff --git a/redis/client.py b/redis/client.py index 881fd65bd1..02db578878 100755 --- a/redis/client.py +++ b/redis/client.py @@ -647,7 +647,7 @@ class Redis: } @classmethod - def from_url(cls, url, db=None, **kwargs): + def from_url(cls, url, **kwargs): """ Return a Redis client object configured from the given URL @@ -659,28 +659,35 @@ def from_url(cls, url, db=None, **kwargs): Three URL schemes are supported: - - ```redis://`` - `_ creates a - normal TCP socket connection - - ```rediss://`` - `_ creates a - SSL wrapped TCP socket connection - - ``unix://`` creates a Unix Domain Socket connection + - `redis://` creates a TCP socket connection. See more at: + + - `rediss://` creates a SSL wrapped TCP socket connection. See more at: + + - ``unix://``: creates a Unix Domain Socket connection. - There are several ways to specify a database number. The parse function - will return the first specified option: + The username, password, hostname, path and all querystring values + are passed through urllib.parse.unquote in order to replace any + percent-encoded values with their corresponding characters. + + There are several ways to specify a database number. The first value + found will be used: 1. A ``db`` querystring option, e.g. redis://localhost?db=0 - 2. If using the redis:// scheme, the path argument of the url, e.g. - redis://localhost/0 - 3. The ``db`` argument to this function. + 2. If using the redis:// or rediss:// schemes, the path argument + of the url, e.g. redis://localhost/0 + 3. A ``db`` keyword argument to this function. + + If none of these options are specified, the default db=0 is used. - If none of these options are specified, db=0 is used. + All querystring options are cast to their appropriate Python types. + Boolean arguments can be specified with string values "True"/"False" + or "Yes"/"No". Values that cannot be properly cast cause a + ``ValueError`` to be raised. Once parsed, the querystring arguments + and keyword arguments are passed to the ``ConnectionPool``'s + class initializer. In the case of conflicting arguments, querystring + arguments always win. - Any additional querystring arguments and keyword arguments will be - passed along to the ConnectionPool class's initializer. In the case - of conflicting arguments, querystring arguments always win. """ - connection_pool = ConnectionPool.from_url(url, db=db, **kwargs) + connection_pool = ConnectionPool.from_url(url, **kwargs) return cls(connection_pool=connection_pool) def __init__(self, host='localhost', port=6379, diff --git a/redis/connection.py b/redis/connection.py index a29f9b2fa6..4a855b3568 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -919,6 +919,7 @@ def to_bool(value): URL_QUERY_ARGUMENT_PARSERS = { + 'db': int, 'socket_timeout': float, 'socket_connect_timeout': float, 'socket_keepalive': to_bool, @@ -929,6 +930,59 @@ def to_bool(value): } +def parse_url(url): + url = urlparse(url) + kwargs = {} + + for name, value in parse_qs(url.query).items(): + if value and len(value) > 0: + value = unquote(value[0]) + parser = URL_QUERY_ARGUMENT_PARSERS.get(name) + if parser: + try: + kwargs[name] = parser(value) + except (TypeError, ValueError): + raise ValueError( + "Invalid value for `%s` in connection URL." % name + ) + else: + kwargs[name] = value + + if url.username: + kwargs['username'] = unquote(url.username) + if url.password: + kwargs['password'] = unquote(url.password) + + # We only support redis://, rediss:// and unix:// schemes. + if url.scheme == 'unix': + if url.path: + kwargs['path'] = unquote(url.path) + kwargs['connection_class'] = UnixDomainSocketConnection + + elif url.scheme in ('redis', 'rediss'): + if url.hostname: + kwargs['host'] = unquote(url.hostname) + if url.port: + kwargs['port'] = int(url.port) + + # If there's a path argument, use it as the db argument if a + # querystring value wasn't specified + if url.path and 'db' not in kwargs: + try: + kwargs['db'] = int(unquote(url.path).replace('/', '')) + except (AttributeError, ValueError): + pass + + if url.scheme == 'rediss': + kwargs['connection_class'] = SSLConnection + else: + valid_schemes = 'redis://, rediss://, unix://' + raise ValueError('Redis URL must specify one of the following ' + 'schemes (%s)' % valid_schemes) + + return kwargs + + class ConnectionPool: """ Create a connection pool. ``If max_connections`` is set, then this @@ -943,7 +997,7 @@ class ConnectionPool: ``connection_class``. """ @classmethod - def from_url(cls, url, db=None, decode_components=False, **kwargs): + def from_url(cls, url, **kwargs): """ Return a connection pool configured from the given URL. @@ -955,114 +1009,35 @@ def from_url(cls, url, db=None, decode_components=False, **kwargs): Three URL schemes are supported: - - `redis:// - `_ creates a - normal TCP socket connection - - `rediss:// - `_ creates - a SSL wrapped TCP socket connection - - ``unix://`` creates a Unix Domain Socket connection + - `redis://` creates a TCP socket connection. See more at: + + - `rediss://` creates a SSL wrapped TCP socket connection. See more at: + + - ``unix://``: creates a Unix Domain Socket connection. - There are several ways to specify a database number. The parse function - will return the first specified option: - 1. A ``db`` querystring option, e.g. redis://localhost?db=0 - 2. If using the redis:// scheme, the path argument of the url, e.g. - redis://localhost/0 - 3. The ``db`` argument to this function. - - If none of these options are specified, db=0 is used. - - The ``decode_components`` argument allows this function to work with - percent-encoded URLs. If this argument is set to ``True`` all ``%xx`` - escapes will be replaced by their single-character equivalents after - the URL has been parsed. This only applies to the ``hostname``, - ``path``, ``username`` and ``password`` components. - - Any additional querystring arguments and keyword arguments will be - passed along to the ConnectionPool class's initializer. The querystring - arguments ``socket_connect_timeout`` and ``socket_timeout`` if supplied - are parsed as float values. The arguments ``socket_keepalive`` and - ``retry_on_timeout`` are parsed to boolean values that accept - True/False, Yes/No values to indicate state. Invalid types cause a - ``UserWarning`` to be raised. In the case of conflicting arguments, - querystring arguments always win. + The username, password, hostname, path and all querystring values + are passed through urllib.parse.unquote in order to replace any + percent-encoded values with their corresponding characters. + There are several ways to specify a database number. The first value + found will be used: + 1. A ``db`` querystring option, e.g. redis://localhost?db=0 + 2. If using the redis:// or rediss:// schemes, the path argument + of the url, e.g. redis://localhost/0 + 3. A ``db`` keyword argument to this function. + + If none of these options are specified, the default db=0 is used. + + All querystring options are cast to their appropriate Python types. + Boolean arguments can be specified with string values "True"/"False" + or "Yes"/"No". Values that cannot be properly cast cause a + ``ValueError`` to be raised. Once parsed, the querystring arguments + and keyword arguments are passed to the ``ConnectionPool``'s + class initializer. In the case of conflicting arguments, querystring + arguments always win. """ - url = urlparse(url) - url_options = {} - - for name, value in parse_qs(url.query).items(): - if value and len(value) > 0: - parser = URL_QUERY_ARGUMENT_PARSERS.get(name) - if parser: - try: - url_options[name] = parser(value[0]) - except (TypeError, ValueError): - warnings.warn(UserWarning( - "Invalid value for `%s` in connection URL." % name - )) - else: - url_options[name] = value[0] - - if decode_components: - username = unquote(url.username) if url.username else None - password = unquote(url.password) if url.password else None - path = unquote(url.path) if url.path else None - hostname = unquote(url.hostname) if url.hostname else None - else: - username = url.username or None - password = url.password or None - path = url.path - hostname = url.hostname - - # We only support redis://, rediss:// and unix:// schemes. - if url.scheme == 'unix': - url_options.update({ - 'username': username, - 'password': password, - 'path': path, - 'connection_class': UnixDomainSocketConnection, - }) - - elif url.scheme in ('redis', 'rediss'): - url_options.update({ - 'host': hostname, - 'port': int(url.port or 6379), - 'username': username, - 'password': password, - }) - - # If there's a path argument, use it as the db argument if a - # querystring value wasn't specified - if 'db' not in url_options and path: - try: - url_options['db'] = int(path.replace('/', '')) - except (AttributeError, ValueError): - pass - - if url.scheme == 'rediss': - url_options['connection_class'] = SSLConnection - else: - valid_schemes = ', '.join(('redis://', 'rediss://', 'unix://')) - raise ValueError('Redis URL must specify one of the following ' - 'schemes (%s)' % valid_schemes) - - # last shot at the db value - url_options['db'] = int(url_options.get('db', db or 0)) - - # update the arguments from the URL values + url_options = parse_url(url) kwargs.update(url_options) - - # backwards compatability - if 'charset' in kwargs: - warnings.warn(DeprecationWarning( - '"charset" is deprecated. Use "encoding" instead')) - kwargs['encoding'] = kwargs.pop('charset') - if 'errors' in kwargs: - warnings.warn(DeprecationWarning( - '"errors" is deprecated. Use "encoding_errors" instead')) - kwargs['encoding_errors'] = kwargs.pop('errors') - return cls(**kwargs) def __init__(self, connection_class=Connection, max_connections=None, diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index c26090b02b..7f9d05453d 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -160,7 +160,6 @@ def test_repr_contains_db_info_tcp(self): pool = redis.ConnectionPool( host='localhost', port=6379, - db=0, client_name='test-client' ) expected = ('ConnectionPool Date: Mon, 17 Aug 2020 10:40:30 +0100 Subject: [PATCH 0086/1164] fix: Align from_url in utils.py to remove DB as a 2nd param --- redis/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redis/utils.py b/redis/utils.py index 3664708f8d..26fb002b89 100644 --- a/redis/utils.py +++ b/redis/utils.py @@ -8,7 +8,7 @@ HIREDIS_AVAILABLE = False -def from_url(url, db=None, **kwargs): +def from_url(url, **kwargs): """ Returns an active Redis client generated from the given database URL. @@ -16,7 +16,7 @@ def from_url(url, db=None, **kwargs): none is provided. """ from redis.client import Redis - return Redis.from_url(url, db, **kwargs) + return Redis.from_url(url, **kwargs) @contextmanager From b80d423cc531c5db972d35ba72424cf9cf8772ff Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 19 Aug 2020 10:46:02 -0700 Subject: [PATCH 0087/1164] Added the ACL LOG command available in Redis 6 `acl_log()` returns a list of dictionaries, each describing a log entry. `acl_log_reset()` instructs the server to truncate the log. Thanks @2014BDuck Fixes #1307 --- CHANGES | 2 ++ redis/client.py | 61 ++++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 29 ++++++++++++++------ tests/test_commands.py | 44 ++++++++++++++++++++++++++++-- 4 files changed, 126 insertions(+), 10 deletions(-) diff --git a/CHANGES b/CHANGES index 2b7dbb8d1f..dfae2a10bc 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,8 @@ @abrookins. #1365 * Added support for the LPOS command available in Redis 6.0.6. Thanks @aparcar #1353/#1354 + * Added support for the ACL LOG command available in Redis 6. Thanks + @2014BDuck. #1307 * 3.5.3 (June 1, 2020) * Restore try/except clauses to __del__ methods. These will be removed in 4.0 when more explicit resource management if enforced. #1339 diff --git a/redis/client.py b/redis/client.py index 02db578878..1560c96afd 100755 --- a/redis/client.py +++ b/redis/client.py @@ -495,6 +495,43 @@ def parse_acl_getuser(response, **options): return data +def parse_acl_log(response, **options): + if response is None: + return None + if isinstance(response, list): + data = [] + for log in response: + log_data = pairs_to_dict(log, True, True) + client_info = log_data.get('client-info', '') + log_data["client-info"] = parse_client_info(client_info) + + # float() is lossy comparing to the "double" in C + log_data["age-seconds"] = float(log_data["age-seconds"]) + data.append(log_data) + else: + data = bool_ok(response) + return data + + +def parse_client_info(value): + """ + Parsing client-info in ACL Log in following format. + "key1=value1 key2=value2 key3=value3" + """ + client_info = {} + infos = value.split(" ") + for info in infos: + key, value = info.split("=") + client_info[key] = value + + # Those fields are definded as int in networking.c + for int_key in {"id", "age", "idle", "db", "sub", "psub", + "multi", "qbuf", "qbuf-free", "obl", + "oll", "omem"}: + client_info[int_key] = int(client_info[int_key]) + return client_info + + def parse_module_result(response): if isinstance(response, ModuleError): raise response @@ -563,6 +600,7 @@ class Redis: 'ACL GETUSER': parse_acl_getuser, 'ACL LIST': lambda r: list(map(str_if_bytes, r)), 'ACL LOAD': bool_ok, + 'ACL LOG': parse_acl_log, 'ACL SAVE': bool_ok, 'ACL SETUSER': bool_ok, 'ACL USERS': lambda r: list(map(str_if_bytes, r)), @@ -949,6 +987,29 @@ def acl_list(self): "Return a list of all ACLs on the server" return self.execute_command('ACL LIST') + def acl_log(self, count=None): + """ + Get ACL logs as a list. + :param int count: Get logs[0:count]. + :rtype: List. + """ + args = [] + if count is not None: + if not isinstance(count, int): + raise DataError('ACL LOG count must be an ' + 'integer') + args.append(count) + + return self.execute_command('ACL LOG', *args) + + def acl_log_reset(self): + """ + Reset ACL logs. + :rtype: Boolean. + """ + args = [b'RESET'] + return self.execute_command('ACL LOG', *args) + def acl_load(self): """ Load ACL rules from the configured ``aclfile``. diff --git a/tests/conftest.py b/tests/conftest.py index 26893dbd6c..cd4d4894a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import random import redis from distutils.version import StrictVersion +from redis.connection import parse_url from unittest.mock import Mock from urllib.parse import urlparse @@ -60,19 +61,31 @@ def skip_unless_arch_bits(arch_bits): reason="server is not {}-bit".format(arch_bits)) -def _get_client(cls, request, single_connection_client=True, **kwargs): +def _get_client(cls, request, single_connection_client=True, flushdb=True, + **kwargs): + """ + Helper for fixtures or tests that need a Redis client + + Uses the "--redis-url" command line argument for connection info. Unlike + ConnectionPool.from_url, keyword arguments to this function override + values specified in the URL. + """ redis_url = request.config.getoption("--redis-url") - client = cls.from_url(redis_url, **kwargs) + url_options = parse_url(redis_url) + url_options.update(kwargs) + pool = redis.ConnectionPool(**url_options) + client = cls(connection_pool=pool) if single_connection_client: client = client.client() if request: def teardown(): - try: - client.flushdb() - except redis.ConnectionError: - # handle cases where a test disconnected a client - # just manually retry the flushdb - client.flushdb() + if flushdb: + try: + client.flushdb() + except redis.ConnectionError: + # handle cases where a test disconnected a client + # just manually retry the flushdb + client.flushdb() client.close() client.connection_pool.disconnect() request.addfinalizer(teardown) diff --git a/tests/test_commands.py b/tests/test_commands.py index 38e2d1a361..211307859a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -9,8 +9,13 @@ from redis.client import parse_info from redis import exceptions -from .conftest import (skip_if_server_version_lt, skip_if_server_version_gte, - skip_unless_arch_bits, REDIS_6_VERSION) +from .conftest import ( + _get_client, + REDIS_6_VERSION, + skip_if_server_version_gte, + skip_if_server_version_lt, + skip_unless_arch_bits, +) @pytest.fixture() @@ -193,6 +198,41 @@ def teardown(): users = r.acl_list() assert 'user %s off -@all' % username in users + @skip_if_server_version_lt(REDIS_6_VERSION) + def test_acl_log(self, r, request): + username = 'redis-py-user' + + def teardown(): + r.acl_deluser(username) + + request.addfinalizer(teardown) + r.acl_setuser(username, enabled=True, reset=True, + commands=['+get', '+set', '+select'], + keys=['cache:*'], nopass=True) + r.acl_log_reset() + + user_client = _get_client(redis.Redis, request, flushdb=False, + username=username) + + # Valid operation and key + assert user_client.set('cache:0', 1) + assert user_client.get('cache:0') == b'1' + + # Invalid key + with pytest.raises(exceptions.NoPermissionError): + user_client.get('violated_cache:0') + + # Invalid operation + with pytest.raises(exceptions.NoPermissionError): + user_client.hset('cache:0', 'hkey', 'hval') + + assert isinstance(r.acl_log(), list) + assert len(r.acl_log()) == 2 + assert len(r.acl_log(count=1)) == 1 + assert isinstance(r.acl_log()[0], dict) + assert 'client-info' in r.acl_log(count=1)[0] + assert r.acl_log_reset() + @skip_if_server_version_lt(REDIS_6_VERSION) def test_acl_setuser_categories_without_prefix_fails(self, r, request): username = 'redis-py-user' From 5a6a50b50db26e4b1c4b7a6cb9b2a2caa8ba5e8b Mon Sep 17 00:00:00 2001 From: ryuichi1208 Date: Mon, 24 Aug 2020 23:18:46 +0900 Subject: [PATCH 0088/1164] Fix Dockerfile (cache clear of apt) --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index eba66f09ed..2ceb725d47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ FROM fkrull/multi-python:latest -RUN apt update && apt install -y pypy pypy-dev pypy3-dev +RUN apt update \ + && apt install -y pypy pypy-dev pypy3-dev \ + && rm -rf /var/lib/apt/lists/* WORKDIR /redis-py From 356da7f042d70b4b7d3808b0900c8bd1cdbac225 Mon Sep 17 00:00:00 2001 From: 2014bduck <2014bduck@gmail.com> Date: Fri, 4 Sep 2020 06:19:43 +0800 Subject: [PATCH 0089/1164] Fixing #1390 modules key in info command (#1393) When modules are present, INFO's response will contain a `modules` key which will be a list of dicts describing each module. Co-authored-by: jiekun.zhu --- redis/client.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 1560c96afd..42d1bfaa53 100755 --- a/redis/client.py +++ b/redis/client.py @@ -141,7 +141,13 @@ def get_value(value): key, value = line.split(':', 1) if key == 'cmdstat_host': key, value = line.rsplit(':', 1) - info[key] = get_value(value) + + if key == 'module': + # Hardcode a list for key 'modules' since there could be + # multiple lines that started with 'module' + info.setdefault('modules', []).append(get_value(value)) + else: + info[key] = get_value(value) else: # if the line isn't splittable, append it to the "__raw__" key info.setdefault('__raw__', []).append(line) From 686116d5c740b0ba89e031ca09c5704af5076fd8 Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Thu, 3 Sep 2020 16:16:20 +0300 Subject: [PATCH 0090/1164] develop and test against redis version 6.0.7 --- docker/base/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 8e952b8e4d..a902e2d709 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1 +1 @@ -FROM redis:6.0.6-buster +FROM redis:6.0.7-buster From ce88fcae9a4371b4595e45c7a037069245d3313a Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Tue, 15 Sep 2020 11:55:20 +0300 Subject: [PATCH 0091/1164] develop and test against redis version 6.0.8 --- docker/base/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index a902e2d709..d8811ba92f 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1 +1 @@ -FROM redis:6.0.7-buster +FROM redis:6.0.8-buster From e4067e8b4441b512cab35039e41160b8a6e3c462 Mon Sep 17 00:00:00 2001 From: Yann <54660067+y4nr1@users.noreply.github.com> Date: Wed, 16 Sep 2020 16:32:30 -0700 Subject: [PATCH 0092/1164] Update docs with info about SSL hostname validation --- README.rst | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 438e33e513..778129328a 100644 --- a/README.rst +++ b/README.rst @@ -129,8 +129,43 @@ this will cause redis-py 3.0 to raise a ConnectionError. This check can be disabled by setting `ssl_cert_reqs` to `None`. Note that doing so removes the security check. Do so at your own risk. -It has been reported that SSL certs received from AWS ElastiCache do not have -proper hostnames and turning off hostname verification is currently required. +Example with hostname verification using a local certificate bundle (linux): + +.. code-block:: pycon + + >>> import redis + >>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, + ssl_ca_certs='/etc/ssl/certs/ca-certificates.crt') + >>> r.set('foo', 'bar') + True + >>> r.get('foo') + b'bar' + +Example with hostname verification using +`certifi `_: + +.. code-block:: pycon + + >>> import redis, certifi + >>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, ssl_ca_certs=certifi.where()) + >>> r.set('foo', 'bar') + True + >>> r.get('foo') + b'bar' + +Example turning off hostname verification (not recommended): + +.. code-block:: pycon + + >>> import redis + >>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, ssl_cert_reqs=None) + >>> r.set('foo', 'bar') + True + >>> r.get('foo') + b'bar' MSET, MSETNX and ZADD @@ -150,7 +185,7 @@ dict is a mapping of element-names -> score. MSET, MSETNX and ZADD now look like: -.. code-block:: python +.. code-block:: pycon def mset(self, mapping): def msetnx(self, mapping): From e635130043d7657d9bee6911995f649e2de9eb04 Mon Sep 17 00:00:00 2001 From: Jack Edge Date: Thu, 24 Sep 2020 15:34:19 +0100 Subject: [PATCH 0093/1164] =?UTF-8?q?=F0=9F=95=B0=EF=B8=8F=20Use=20monoton?= =?UTF-8?q?ic=20clock=20in=20Lock=20(and=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a call to `acquire()`, if the call is `blocking` and has a `blocking_timeout` set, it uses `time.time()` calls to determine when to give up attempting to acquire the lock. However, since `time.time()` is marked as "adjustable", it is possible for it to go backwards or forwards at a rate other than 1 second per second, meaning the spinloop may exit earlier or later than expected. By changing the implementation to use `time.monotonic()`, which is guaranteed to never go backwards, and not be affected by system clock updates, this potential problem is fixed. For the same reason, some time dependent lock tests have also been changed to use `time.monotonic()`. --- redis/lock.py | 4 ++-- tests/test_lock.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/redis/lock.py b/redis/lock.py index e51f169695..e36b2da956 100644 --- a/redis/lock.py +++ b/redis/lock.py @@ -186,14 +186,14 @@ def acquire(self, blocking=None, blocking_timeout=None, token=None): blocking_timeout = self.blocking_timeout stop_trying_at = None if blocking_timeout is not None: - stop_trying_at = mod_time.time() + blocking_timeout + stop_trying_at = mod_time.monotonic() + blocking_timeout while True: if self.do_acquire(token): self.local.token = token return True if not blocking: return False - next_try_at = mod_time.time() + sleep + next_try_at = mod_time.monotonic() + sleep if stop_trying_at is not None and next_try_at > stop_trying_at: return False mod_time.sleep(sleep) diff --git a/tests/test_lock.py b/tests/test_lock.py index 8bb3c7e80a..fa76385221 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -101,10 +101,10 @@ def test_blocking_timeout(self, r): bt = 0.2 sleep = 0.05 lock2 = self.get_lock(r, 'foo', sleep=sleep, blocking_timeout=bt) - start = time.time() + start = time.monotonic() assert not lock2.acquire() # The elapsed duration should be less than the total blocking_timeout - assert bt > (time.time() - start) > bt - sleep + assert bt > (time.monotonic() - start) > bt - sleep lock1.release() def test_context_manager(self, r): @@ -126,11 +126,11 @@ def test_high_sleep_small_blocking_timeout(self, r): sleep = 60 bt = 1 lock2 = self.get_lock(r, 'foo', sleep=sleep, blocking_timeout=bt) - start = time.time() + start = time.monotonic() assert not lock2.acquire() # the elapsed timed is less than the blocking_timeout as the lock is # unattainable given the sleep/blocking_timeout configuration - assert bt > (time.time() - start) + assert bt > (time.monotonic() - start) lock1.release() def test_releasing_unlocked_lock_raises_error(self, r): From 15dafb1414f05ce24ef336fc539e06ad6a2b3d19 Mon Sep 17 00:00:00 2001 From: Brad Solomon Date: Fri, 10 May 2019 10:12:51 -0400 Subject: [PATCH 0094/1164] Note that redis-py does not support Cluster Mode --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 778129328a..fccea62440 100644 --- a/README.rst +++ b/README.rst @@ -921,6 +921,12 @@ that return Python iterators for convenience: `scan_iter`, `hscan_iter`, B 2 C 3 +Cluster Mode +^^^^^^^^^^^^ + +redis-py does not currently support `Cluster Mode +`_. + Author ^^^^^^ From a404ad3dee93b3b0deb90b4075fd28734d78a282 Mon Sep 17 00:00:00 2001 From: Abhimanyu Deora Date: Mon, 26 Oct 2020 11:16:23 -0500 Subject: [PATCH 0095/1164] Add optional exception handler to PubSubWorkerThread (#1395) Add optional exception handler to PubSubWorkerThread Co-authored-by: Abhimanyu Deora --- README.rst | 14 ++++++++++++++ redis/client.py | 23 ++++++++++++++++++----- tests/test_pubsub.py | 24 ++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index fccea62440..389d5b478c 100644 --- a/README.rst +++ b/README.rst @@ -732,6 +732,20 @@ subscribed to patterns or channels that don't have message handlers attached. # when it's time to shut it down... >>> thread.stop() +`run_in_thread` also supports an optional exception handler, which lets you +catch exceptions that occur within the worker thread and handle them +appropriately. The exception handler will take as arguments the exception +itself, the pubsub object, and the worker thread returned by `run_in_thread`. + +.. code-block:: pycon + >>> p.subscribe(**{'my-channel': my_handler}) + >>> def exception_handler(ex, pubsub, thread): + >>> print(ex) + >>> thread.stop() + >>> thread.join(timeout=1.0) + >>> pubsub.close() + >>> thread = p.run_in_thread(exception_handler=exception_handler) + A PubSub object adheres to the same encoding semantics as the client instance it was created from. Any channel or pattern that's unicode will be encoded using the `charset` specified on the client before being sent to Redis. If the diff --git a/redis/client.py b/redis/client.py index 42d1bfaa53..08b03145c5 100755 --- a/redis/client.py +++ b/redis/client.py @@ -3803,7 +3803,8 @@ def handle_message(self, response, ignore_subscribe_messages=False): return message - def run_in_thread(self, sleep_time=0, daemon=False): + def run_in_thread(self, sleep_time=0, daemon=False, + exception_handler=None): for channel, handler in self.channels.items(): if handler is None: raise PubSubError("Channel: '%s' has no handler registered" % @@ -3813,17 +3814,24 @@ def run_in_thread(self, sleep_time=0, daemon=False): raise PubSubError("Pattern: '%s' has no handler registered" % pattern) - thread = PubSubWorkerThread(self, sleep_time, daemon=daemon) + thread = PubSubWorkerThread( + self, + sleep_time, + daemon=daemon, + exception_handler=exception_handler + ) thread.start() return thread class PubSubWorkerThread(threading.Thread): - def __init__(self, pubsub, sleep_time, daemon=False): + def __init__(self, pubsub, sleep_time, daemon=False, + exception_handler=None): super().__init__() self.daemon = daemon self.pubsub = pubsub self.sleep_time = sleep_time + self.exception_handler = exception_handler self._running = threading.Event() def run(self): @@ -3833,8 +3841,13 @@ def run(self): pubsub = self.pubsub sleep_time = self.sleep_time while self._running.is_set(): - pubsub.get_message(ignore_subscribe_messages=True, - timeout=sleep_time) + try: + pubsub.get_message(ignore_subscribe_messages=True, + timeout=sleep_time) + except BaseException as e: + if self.exception_handler is None: + raise + self.exception_handler(e, pubsub, self) pubsub.close() def stop(self): diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index ab9f09c84e..abeaecba38 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -1,6 +1,9 @@ import pytest +import threading import time +from unittest import mock + import redis from redis.exceptions import ConnectionError @@ -543,3 +546,24 @@ def test_get_message_with_timeout_returns_none(self, r): p.subscribe('foo') assert wait_for_message(p) == make_message('subscribe', 'foo', 1) assert p.get_message(timeout=0.01) is None + + +class TestPubSubWorkerThread: + def test_pubsub_worker_thread_exception_handler(self, r): + event = threading.Event() + + def exception_handler(ex, pubsub, thread): + thread.stop() + event.set() + + p = r.pubsub() + p.subscribe(**{'foo': lambda m: m}) + with mock.patch.object(p, 'get_message', + side_effect=Exception('error')): + pubsub_thread = p.run_in_thread( + exception_handler=exception_handler + ) + + assert event.wait(timeout=1.0) + pubsub_thread.join(timeout=1.0) + assert not pubsub_thread.is_alive() From 66973e4e6cdeb9bc7d71966ca14717a1bdbddec2 Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Wed, 28 Oct 2020 09:54:13 +0200 Subject: [PATCH 0096/1164] develop and test against redis version 6.0.9 --- docker/base/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index d8811ba92f..003eac1f89 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1 +1 @@ -FROM redis:6.0.8-buster +FROM redis:6.0.9-buster From 1345dc48874e10d895e87ddbaba3f8b6e679d833 Mon Sep 17 00:00:00 2001 From: Felipe Machado <462154+felipou@users.noreply.github.com> Date: Mon, 9 Nov 2020 05:07:45 -0300 Subject: [PATCH 0097/1164] Add more documentation about encoding of strings (#1417) Additional docs about string encoding/decoding --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 389d5b478c..a4b6e167b3 100644 --- a/README.rst +++ b/README.rst @@ -75,6 +75,12 @@ specify `decode_responses=True` to `Redis.__init__`. In this case, any Redis command that returns a string type will be decoded with the `encoding` specified. +The default encoding is "utf-8", but this can be customized with the `encoding` +argument to the `redis.Redis` class. The `encoding` will be used to +automatically encode any strings passed to commands, such as key names and +values. When `decode_responses=True`, string data returned from commands +will be decoded with the same `encoding`. + Upgrading from redis-py 2.X to 3.0 ---------------------------------- From 8c176cdc7f36e2cbcd3254768766035a7b7cd8b3 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 9 Nov 2020 10:08:31 +0200 Subject: [PATCH 0098/1164] Sponsored (#1418) Add note that redis-py is sponsored by Redis Labs --- README.rst | 8 ++++++++ docs/logo-redislabs.png | Bin 0 -> 18950 bytes 2 files changed, 8 insertions(+) create mode 100644 docs/logo-redislabs.png diff --git a/README.rst b/README.rst index a4b6e167b3..b71c760300 100644 --- a/README.rst +++ b/README.rst @@ -959,3 +959,11 @@ Special thanks to: which some of the socket code is still used. * Alexander Solovyov for ideas on the generic response callback system. * Paul Hubbard for initial packaging support. + + +Sponsored by +^^^^^^^^^^^^ + +.. image:: ./docs/logo-redislabs.png + :alt: RedisLabs + :target: https://www.redislabs.com diff --git a/docs/logo-redislabs.png b/docs/logo-redislabs.png new file mode 100644 index 0000000000000000000000000000000000000000..cde2d399e2851864d23803279a0939a499a10cea GIT binary patch literal 18950 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 Date: Sun, 22 Nov 2020 15:52:44 -0500 Subject: [PATCH 0099/1164] Add support for the ABSTTL option of the RESTORE command. (#1423) Add support for the ABSTTL option of the RESTORE command. --- CHANGES | 2 ++ redis/client.py | 11 ++++++++++- tests/test_commands.py | 13 +++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index dfae2a10bc..f4974b8299 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,8 @@ @aparcar #1353/#1354 * Added support for the ACL LOG command available in Redis 6. Thanks @2014BDuck. #1307 + * Added support for ABSTTL option of the RESTORE command available in + Redis 5.0. Thanks @charettes. #1423 * 3.5.3 (June 1, 2020) * Restore try/except clauses to __del__ methods. These will be removed in 4.0 when more explicit resource management if enforced. #1339 diff --git a/redis/client.py b/redis/client.py index 08b03145c5..18553b96ec 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1811,14 +1811,23 @@ def renamenx(self, src, dst): "Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist" return self.execute_command('RENAMENX', src, dst) - def restore(self, name, ttl, value, replace=False): + def restore(self, name, ttl, value, replace=False, absttl=False): """ Create a key using the provided serialized value, previously obtained using DUMP. + + ``replace`` allows an existing key on ``name`` to be overridden. If + it's not specified an error is raised on collision. + + ``absttl`` if True, specified ``ttl`` should represent an absolute Unix + timestamp in milliseconds in which the key will expire. (Redis 5.0 or + greater). """ params = [name, ttl, value] if replace: params.append('REPLACE') + if absttl: + params.append('ABSTTL') return self.execute_command('RESTORE', *params) def set(self, name, value, diff --git a/tests/test_commands.py b/tests/test_commands.py index 211307859a..d1f85b7306 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -642,6 +642,19 @@ def test_dump_and_restore_and_replace(self, r): r.restore('a', 0, dumped, replace=True) assert r['a'] == b'bar' + @skip_if_server_version_lt('5.0.0') + def test_dump_and_restore_absttl(self, r): + r['a'] = 'foo' + dumped = r.dump('a') + del r['a'] + ttl = int( + (redis_server_time(r) + datetime.timedelta(minutes=1)).timestamp() + * 1000 + ) + r.restore('a', ttl, dumped, absttl=True) + assert r['a'] == b'foo' + assert 0 < r.ttl('a') <= 61 + def test_exists(self, r): assert r.exists('a') == 0 r['a'] = 'foo' From c7e26ae6749f63b9ebf65c023f270b2ea2b9536a Mon Sep 17 00:00:00 2001 From: alxasfuck Date: Wed, 12 May 2021 23:52:01 +0200 Subject: [PATCH 0100/1164] Remove blocking behaviour from context manager __enter__ --- redis/lock.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/redis/lock.py b/redis/lock.py index e36b2da956..326dbaf98e 100644 --- a/redis/lock.py +++ b/redis/lock.py @@ -149,9 +149,7 @@ def register_scripts(self): client.register_script(cls.LUA_REACQUIRE_SCRIPT) def __enter__(self): - # force blocking, as otherwise the user would have to check whether - # the lock was actually acquired or not. - if self.acquire(blocking=True): + if self.acquire(): return self raise LockError("Unable to acquire lock within the time specified") From bc5854217b4e94eb7a33e3da5738858a17135ca5 Mon Sep 17 00:00:00 2001 From: Ian Bucad Date: Thu, 15 Apr 2021 15:30:29 +1000 Subject: [PATCH 0101/1164] Return index 4 as the command if not a list command is always a list. If index 3 is not a list, assume Redis Enterprise and return index 4 instead --- redis/client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 18553b96ec..59575cd835 100755 --- a/redis/client.py +++ b/redis/client.py @@ -404,7 +404,12 @@ def parse_slowlog_get(response, **options): 'id': item[0], 'start_time': int(item[1]), 'duration': int(item[2]), - 'command': space.join(item[3]) + 'command': + # Redis Enterprise injects another entry at index [3], which has + # the complexity info (i.e. the value N in case the command has + # an O(N) complexity) instead of the command. + space.join(item[3]) if isinstance(item[3], list) else + space.join(item[4]) } for item in response] From ed8c2df14abf166cdb7e1a8bc80c675cf684eeb0 Mon Sep 17 00:00:00 2001 From: Roey Prat Date: Mon, 9 Nov 2020 18:20:40 +0200 Subject: [PATCH 0102/1164] use github actions instead of travis-CI --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/integration.yaml | 15 +++++++++++++++ .travis.yml | 14 -------------- README.rst | 4 ++-- docker-compose.yml | 10 +--------- docker-entry.sh | 2 +- tox.ini | 3 +-- 7 files changed, 21 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/integration.yaml delete mode 100644 .travis.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3b8e7ba628..2bbc804d08 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,7 +3,7 @@ _Please make sure to review and check all of these items:_ - [ ] Does `$ tox` pass with this change (including linting)? -- [ ] Does travis tests pass with this change (enable it first in your forked repo and wait for the travis build to finish)? +- [ ] 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)? diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml new file mode 100644 index 0000000000..8cb4af92c0 --- /dev/null +++ b/.github/workflows/integration.yaml @@ -0,0 +1,15 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: test + run: make test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4b09cfcced..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -env: - - DOCKER_COMPOSE_VERSION=1.26.2 - -before_install: - - sudo rm /usr/local/bin/docker-compose - - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose - - chmod +x docker-compose - - sudo mv docker-compose /usr/local/bin - -services: - - docker - -script: - - make test diff --git a/README.rst b/README.rst index b71c760300..43a7de034a 100644 --- a/README.rst +++ b/README.rst @@ -3,8 +3,8 @@ redis-py The Python interface to the Redis key-value store. -.. image:: https://secure.travis-ci.org/andymccurdy/redis-py.svg?branch=master - :target: https://travis-ci.org/andymccurdy/redis-py +.. image:: https://github.com/andymccurdy/redis-py/workflows/CI/badge.svg?branch=master + :target: https://github.com/andymccurdy/redis-py/actions?query=workflow%3ACI+branch%3Amaster .. image:: https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat :target: https://redis-py.readthedocs.io/en/stable/ .. image:: https://badge.fury.io/py/redis.svg diff --git a/docker-compose.yml b/docker-compose.yml index 247688ad8c..103a51b19c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,15 +50,7 @@ services: CODECOV_TOKEN: CODECOV_URL: SHIPPABLE: - TRAVIS: - TRAVIS_BRANCH: - TRAVIS_COMMIT: - TRAVIS_JOB_ID: - TRAVIS_JOB_NUMBER: - TRAVIS_OS_NAME: - TRAVIS_PULL_REQUEST: - TRAVIS_REPO_SLUG: - TRAVIS_TAG: + GITHUB_ACTIONS: VCS_BRANCH_NAME: VCS_COMMIT_ID: VCS_PULL_REQUEST: diff --git a/docker-entry.sh b/docker-entry.sh index f63a8c5f14..dc119dcdd8 100755 --- a/docker-entry.sh +++ b/docker-entry.sh @@ -9,7 +9,7 @@ REDIS_MASTER="${REDIS_MASTER_HOST}":"${REDIS_MASTER_PORT}" echo "Testing against Redis Server: ${REDIS_MASTER}" # skip the "codecov" env if not running on Travis -if [ -z ${TRAVIS-} ]; then +if [ "${GITHUB_ACTIONS}" = true ] ; then echo "Skipping codecov" export TOX_SKIP_ENV="codecov" fi diff --git a/tox.ini b/tox.ini index c783bf06b8..19f9aadd87 100644 --- a/tox.ini +++ b/tox.ini @@ -43,8 +43,7 @@ passenv = CI_* CODECOV_* SHIPPABLE - TRAVIS - TRAVIS_* + GITHUB_* VCS_* [testenv:covreport] From b25cc4ea905c0f01621826b3a7821970fdb313b8 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 30 Jun 2021 16:12:44 -0700 Subject: [PATCH 0103/1164] daemonize the thread to see if the tests will continue --- tests/test_pubsub.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index abeaecba38..17a1621ca3 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -1,14 +1,12 @@ -import pytest import threading import time - from unittest import mock +import pytest import redis from redis.exceptions import ConnectionError -from .conftest import _get_client -from .conftest import skip_if_server_version_lt +from .conftest import _get_client, skip_if_server_version_lt def wait_for_message(pubsub, timeout=0.1, ignore_subscribe_messages=False): @@ -561,6 +559,7 @@ def exception_handler(ex, pubsub, thread): with mock.patch.object(p, 'get_message', side_effect=Exception('error')): pubsub_thread = p.run_in_thread( + daemon=True, exception_handler=exception_handler ) From fc621bd1b4de35fa088f87895e5fa1ade6a9150c Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Wed, 30 Jun 2021 16:14:26 -0700 Subject: [PATCH 0104/1164] run CI on all branches --- .github/workflows/integration.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 8cb4af92c0..f08a2c238f 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -2,9 +2,7 @@ name: CI on: push: - branches: [ master ] pull_request: - branches: [ master ] jobs: integration: From cb97b9bdd47bf5a92f4efa88e52591344a08b98e Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 15 Jul 2021 08:57:41 +0300 Subject: [PATCH 0105/1164] changing unit tests to account for defaults in redis flags (#1499) Co-authored-by: Andy McCurdy --- tests/test_commands.py | 37 ++++++++++++++++++------------------- tests/test_pubsub.py | 4 ++++ tox.ini | 2 +- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index d1f85b7306..2da4a89e63 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -108,25 +108,24 @@ def teardown(): # test enabled=False assert r.acl_setuser(username, enabled=False, reset=True) - assert r.acl_getuser(username) == { - 'categories': ['-@all'], - 'commands': [], - 'enabled': False, - 'flags': ['off'], - 'keys': [], - 'passwords': [], - } + acl = r.acl_getuser(username) + assert acl['categories'] == ['-@all'] + assert acl['commands'] == [] + assert acl['keys'] == [] + assert acl['passwords'] == [] + assert 'off' in acl['flags'] + assert acl['enabled'] is False # test nopass=True assert r.acl_setuser(username, enabled=True, reset=True, nopass=True) - assert r.acl_getuser(username) == { - 'categories': ['-@all'], - 'commands': [], - 'enabled': True, - 'flags': ['on', 'nopass'], - 'keys': [], - 'passwords': [], - } + acl = r.acl_getuser(username) + assert acl['categories'] == ['-@all'] + assert acl['commands'] == [] + assert acl['keys'] == [] + assert acl['passwords'] == [] + assert 'on' in acl['flags'] + assert 'nopass' in acl['flags'] + assert acl['enabled'] is True # test all args assert r.acl_setuser(username, enabled=True, reset=True, @@ -138,7 +137,7 @@ def teardown(): assert set(acl['categories']) == set(['-@all', '+@set', '+@hash']) assert set(acl['commands']) == set(['+get', '+mget', '-hset']) assert acl['enabled'] is True - assert acl['flags'] == ['on'] + assert 'on' in acl['flags'] assert set(acl['keys']) == set([b'cache:*', b'objects:*']) assert len(acl['passwords']) == 2 @@ -157,7 +156,7 @@ def teardown(): assert set(acl['categories']) == set(['-@all', '+@set', '+@hash']) assert set(acl['commands']) == set(['+get', '+mget']) assert acl['enabled'] is True - assert acl['flags'] == ['on'] + assert 'on' in acl['flags'] assert set(acl['keys']) == set([b'cache:*', b'objects:*']) assert len(acl['passwords']) == 2 @@ -196,7 +195,7 @@ def teardown(): assert r.acl_setuser(username, enabled=False, reset=True) users = r.acl_list() - assert 'user %s off -@all' % username in users + assert len(users) == 2 @skip_if_server_version_lt(REDIS_6_VERSION) def test_acl_log(self, r, request): diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 17a1621ca3..6a4f0aafa4 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -1,6 +1,7 @@ import threading import time from unittest import mock +import platform import pytest import redis @@ -547,6 +548,9 @@ def test_get_message_with_timeout_returns_none(self, r): class TestPubSubWorkerThread: + + @pytest.mark.skipif(platform.python_implementation() == 'PyPy', + reason="Pypy threading issue") def test_pubsub_worker_thread_exception_handler(self, r): event = threading.Event() diff --git a/tox.ini b/tox.ini index 19f9aadd87..db7492d18e 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ addopts = -s [tox] minversion = 2.4 -envlist = {py35,py36,py37,py38,pypy3}-{plain,hiredis}, flake8, covreport, codecov +envlist = {py35,py36,py37,py38,py39,pypy3}-{plain,hiredis}, flake8, covreport, codecov [testenv] deps = From 9ce7bb2858065c0dc5ac91287b8716def9919291 Mon Sep 17 00:00:00 2001 From: malinaa96 <52569986+malinaa96@users.noreply.github.com> Date: Tue, 20 Jul 2021 07:44:56 +0200 Subject: [PATCH 0106/1164] Add support for COPY command new in Redis 6.2 (#1492) --- redis/client.py | 20 +++++++++++++++++++- tests/test_commands.py | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 59575cd835..72178f3068 100755 --- a/redis/client.py +++ b/redis/client.py @@ -561,7 +561,7 @@ class Redis: """ RESPONSE_CALLBACKS = { **string_keys_to_dict( - 'AUTH EXPIRE EXPIREAT HEXISTS HMSET MOVE MSETNX PERSIST ' + 'AUTH COPY EXPIRE EXPIREAT HEXISTS HMSET MOVE MSETNX PERSIST ' 'PSETEX RENAMENX SISMEMBER SMOVE SETEX SETNX', bool ), @@ -1612,6 +1612,24 @@ def bitpos(self, key, bit, start=None, end=None): "when end is specified") return self.execute_command('BITPOS', *params) + def copy(self, source, destination, destination_db=None, replace=False): + """ + Copy the value stored in the ``source`` key to the ``destination`` key. + + ``destination_db`` an alternative destination database. By default, + the ``destination`` key is created in the source Redis database. + + ``replace`` whether the ``destination`` key should be removed before + copying the value to it. By default, the value is not copied if + the ``destination`` key already exists. + """ + params = [source, destination] + if destination_db is not None: + params.extend(["DB", destination_db]) + if replace: + params.append("REPLACE") + return self.execute_command('COPY', *params) + def decr(self, name, amount=1): """ Decrements the value of ``key`` by ``amount``. If no key exists, diff --git a/tests/test_commands.py b/tests/test_commands.py index 2da4a89e63..a5a7e45e2f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -578,6 +578,29 @@ def test_bitpos_wrong_arguments(self, r): with pytest.raises(exceptions.RedisError): r.bitpos(key, 7) == 12 + @skip_if_server_version_lt('6.2.0') + def test_copy(self, r): + assert r.copy("a", "b") == 0 + r.set("a", "foo") + assert r.copy("a", "b") == 1 + assert r.get("a") == b"foo" + assert r.get("b") == b"foo" + + @skip_if_server_version_lt('6.2.0') + def test_copy_and_replace(self, r): + r.set("a", "foo1") + r.set("b", "foo2") + assert r.copy("a", "b") == 0 + assert r.copy("a", "b", replace=True) == 1 + + @skip_if_server_version_lt('6.2.0') + def test_copy_to_another_database(self, request): + r0 = _get_client(redis.Redis, request, db=0) + r1 = _get_client(redis.Redis, request, db=1) + r0.set("a", "foo") + assert r0.copy("a", "b", destination_db=1) == 1 + assert r1.get("b") == b"foo" + def test_decr(self, r): assert r.decr('a') == -1 assert r['a'] == b'-1' From 3c244af9e7c820d38135562d9385753f5bbb60e6 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 22 Jul 2021 18:50:24 +0300 Subject: [PATCH 0107/1164] getex (#1515) * getex * flake8 fix * comments --- redis/client.py | 51 ++++++++++++++++++++++++++++++++++++++++++ tests/test_commands.py | 15 +++++++++++++ 2 files changed, 66 insertions(+) diff --git a/redis/client.py b/redis/client.py index 72178f3068..8b1b35392c 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1689,6 +1689,57 @@ def get(self, name): """ return self.execute_command('GET', name) + def getex(self, name, + ex=None, px=None, exat=None, pxat=None, persist=False): + """ + Get the value of key and optionally set its expiration. + GETEX is similar to GET, but is a write command with + additional options. All time parameters can be given as + datetime.timedelta or integers. + + ``ex`` sets an expire flag on key ``name`` for ``ex`` seconds. + + ``px`` sets an expire flag on key ``name`` for ``px`` milliseconds. + + ``exat`` sets an expire flag on key ``name`` for ``ex`` seconds, + specified in unix time. + + ``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds, + specified in unix time. + + ``persist`` remove the time to live associated with ``name``. + """ + + pieces = [] + # similar to set command + if ex is not None: + pieces.append('EX') + if isinstance(ex, datetime.timedelta): + ex = int(ex.total_seconds()) + pieces.append(ex) + if px is not None: + pieces.append('PX') + if isinstance(px, datetime.timedelta): + px = int(px.total_seconds() * 1000) + pieces.append(px) + # similar to pexpireat command + if exat is not None: + pieces.append('EXAT') + if isinstance(exat, datetime.datetime): + s = int(exat.microsecond / 1000000) + exat = int(mod_time.mktime(exat.timetuple())) + s + pieces.append(exat) + if pxat is not None: + pieces.append('PXAT') + if isinstance(pxat, datetime.datetime): + ms = int(pxat.microsecond / 1000) + pxat = int(mod_time.mktime(pxat.timetuple())) * 1000 + ms + pieces.append(pxat) + if persist: + pieces.append('PERSIST') + + return self.execute_command('GETEX', name, *pieces) + def __getitem__(self, name): """ Return the value at key ``name``, raises a KeyError if the key diff --git a/tests/test_commands.py b/tests/test_commands.py index a5a7e45e2f..088404a23c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -727,6 +727,21 @@ def test_get_and_set(self, r): assert r.get('integer') == str(integer).encode() assert r.get('unicode_string').decode('utf-8') == unicode_string + @skip_if_server_version_lt('6.2.0') + def test_getex(self, r): + r.set('a', 1) + assert r.getex('a') == b'1' + assert r.ttl('a') == -1 + assert r.getex('a', ex=60) == b'1' + assert r.ttl('a') == 60 + assert r.getex('a', px=6000) == b'1' + assert r.ttl('a') == 6 + expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) + assert r.getex('a', pxat=expire_at) == b'1' + assert r.ttl('a') <= 60 + assert r.getex('a', persist=True) == b'1' + assert r.ttl('a') == -1 + def test_getitem_and_setitem(self, r): r['a'] = 'bar' assert r['a'] == b'bar' From ad4779eb8200e47a7786f78ca915a246038602c3 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 22 Jul 2021 18:51:13 +0300 Subject: [PATCH 0108/1164] client_list (#1517) --- redis/client.py | 12 ++++++++++-- tests/test_commands.py | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/redis/client.py b/redis/client.py index 8b1b35392c..a838d8775f 100755 --- a/redis/client.py +++ b/redis/client.py @@ -530,7 +530,7 @@ def parse_client_info(value): "key1=value1 key2=value2 key3=value3" """ client_info = {} - infos = value.split(" ") + infos = str_if_bytes(value).split(" ") for info in infos: key, value = info.split("=") client_info[key] = value @@ -538,7 +538,7 @@ def parse_client_info(value): # Those fields are definded as int in networking.c for int_key in {"id", "age", "idle", "db", "sub", "psub", "multi", "qbuf", "qbuf-free", "obl", - "oll", "omem"}: + "argv-mem", "oll", "omem", "tot-mem"}: client_info[int_key] = int(client_info[int_key]) return client_info @@ -620,6 +620,7 @@ class Redis: 'CLIENT ID': int, 'CLIENT KILL': parse_client_kill, 'CLIENT LIST': parse_client_list, + 'CLIENT INFO': parse_client_info, 'CLIENT SETNAME': bool_ok, 'CLIENT UNBLOCK': lambda r: r and int(r) == 1 or False, 'CLIENT PAUSE': bool_ok, @@ -1243,6 +1244,13 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, skipme=None): " must specify at least one filter") return self.execute_command('CLIENT KILL', *args) + def client_info(self): + """ + Returns information and statistics about the current + client connection. + """ + return self.execute_command('CLIENT INFO') + def client_list(self, _type=None): """ Returns a list of currently connected clients. diff --git a/tests/test_commands.py b/tests/test_commands.py index 088404a23c..9884035e7d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -281,6 +281,12 @@ def test_client_list(self, r): assert isinstance(clients[0], dict) assert 'addr' in clients[0] + @skip_if_server_version_lt('6.2.0') + def test_client_info(self, r): + info = r.client_info() + assert isinstance(info, dict) + assert 'addr' in info + @skip_if_server_version_lt('5.0.0') def test_client_list_type(self, r): with pytest.raises(exceptions.RedisError): From 627db540acd1f1f36db88290d74cbcd75f6bda0c Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 22 Jul 2021 18:51:24 +0300 Subject: [PATCH 0109/1164] hrandfield (#1513) * hrandfield * use mapping in hset * skip if version not fit * remove empty line * flake8 comments * new line for each comment --- redis/client.py | 20 ++++++++++++++++++++ tests/test_commands.py | 13 +++++++++++++ 2 files changed, 33 insertions(+) diff --git a/redis/client.py b/redis/client.py index a838d8775f..57932b9f73 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1879,6 +1879,26 @@ def pttl(self, name): "Returns the number of milliseconds until the key ``name`` will expire" return self.execute_command('PTTL', name) + def hrandfield(self, key, count=None, withvalues=False): + """ + Return a random field from the hash value stored at key. + + count: if the argument is positive, return an array of distinct fields. + If called with a negative count, the behavior changes and the command + is allowed to return the same field multiple times. In this case, + the number of returned fields is the absolute value of the + specified count. + withvalues: The optional WITHVALUES modifier changes the reply so it + includes the respective values of the randomly selected hash fields. + """ + params = [] + if count is not None: + params.append(count) + if withvalues: + params.append("WITHVALUES") + + return self.execute_command("HRANDFIELD", key, *params) + def randomkey(self): "Returns the name of a random key" return self.execute_command('RANDOMKEY') diff --git a/tests/test_commands.py b/tests/test_commands.py index 9884035e7d..27117e3188 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -895,6 +895,19 @@ def test_pttl_no_key(self, r): "PTTL on servers 2.8 and after return -2 when the key doesn't exist" assert r.pttl('a') == -2 + @skip_if_server_version_lt('6.2.0') + def test_hrandfield(self, r): + assert r.hrandfield('key') is None + r.hset('key', mapping={'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5}) + assert r.hrandfield('key') is not None + assert len(r.hrandfield('key', 2)) == 2 + # with values + assert len(r.hrandfield('key', 2, True)) == 4 + # without duplications + assert len(r.hrandfield('key', 10)) == 5 + # with duplications + assert len(r.hrandfield('key', -10)) == 10 + def test_randomkey(self, r): assert r.randomkey() is None for key in ('a', 'b', 'c'): From 57cf7ffc1fbaca2f17d03de38ee19e8af48ac153 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 25 Jul 2021 09:03:55 +0300 Subject: [PATCH 0110/1164] NOMKSTREAM support for XADD (#1507) --- redis/client.py | 7 +++++-- tests/test_commands.py | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/redis/client.py b/redis/client.py index 57932b9f73..f82292600c 100755 --- a/redis/client.py +++ b/redis/client.py @@ -2531,7 +2531,8 @@ def xack(self, name, groupname, *ids): """ return self.execute_command('XACK', name, groupname, *ids) - def xadd(self, name, fields, id='*', maxlen=None, approximate=True): + def xadd(self, name, fields, id='*', maxlen=None, approximate=True, + nomkstream=False): """ Add to a stream. name: name of the stream @@ -2539,7 +2540,7 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True): id: Location to insert this record. By default it is appended. maxlen: truncate old stream members beyond this size approximate: actual stream length may be slightly more than maxlen - + nomkstream: When set to true, do not make a stream """ pieces = [] if maxlen is not None: @@ -2549,6 +2550,8 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True): if approximate: pieces.append(b'~') pieces.append(str(maxlen)) + if nomkstream: + pieces.append(b'NOMKSTREAM') pieces.append(id) if not isinstance(fields, dict) or len(fields) == 0: raise DataError('XADD fields must be a non-empty dict') diff --git a/tests/test_commands.py b/tests/test_commands.py index 27117e3188..c9fdeeffc4 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2247,6 +2247,16 @@ def test_xadd(self, r): r.xadd(stream, {'foo': 'bar'}, maxlen=2, approximate=False) assert r.xlen(stream) == 2 + @skip_if_server_version_lt('6.2.0') + def test_xadd_nomkstream(self, r): + # nomkstream option + stream = 'stream' + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'some': 'other'}, nomkstream=False) + assert r.xlen(stream) == 2 + r.xadd(stream, {'some': 'other'}, nomkstream=True) + assert r.xlen(stream) == 3 + @skip_if_server_version_lt('5.0.0') def test_xclaim(self, r): stream = 'stream' From 88e5bd8938006a6e7bcc626048a9cd4572bbac68 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 25 Jul 2021 09:04:15 +0300 Subject: [PATCH 0111/1164] support for client unpause (#1512) --- redis/client.py | 6 ++++++ tests/test_commands.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/redis/client.py b/redis/client.py index f82292600c..db4778c41c 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1300,6 +1300,12 @@ def client_pause(self, timeout): raise DataError("CLIENT PAUSE timeout must be an integer") return self.execute_command('CLIENT PAUSE', str(timeout)) + def client_unpause(self): + """ + Unpause all redis clients + """ + return self.execute_command('CLIENT UNPAUSE') + def readwrite(self): "Disables read queries for a connection to a Redis Cluster slave node" return self.execute_command('READWRITE') diff --git a/tests/test_commands.py b/tests/test_commands.py index c9fdeeffc4..362e7ab743 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -402,6 +402,10 @@ def test_client_pause(self, r): with pytest.raises(exceptions.RedisError): r.client_pause(timeout='not an integer') + @skip_if_server_version_lt('6.2.0') + def test_client_unpause(self, r): + assert r.client_unpause() == b'OK' + def test_config_get(self, r): data = r.config_get() assert 'maxmemory' in data From b021f5a15e61ac4217b533443eba0a1c56c03ef3 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 25 Jul 2021 09:07:30 +0300 Subject: [PATCH 0112/1164] Implements CLIENT KILL laddr filter (#1506) --- redis/client.py | 6 +++++- tests/test_commands.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index db4778c41c..ef3c82ca9f 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1210,7 +1210,8 @@ def client_kill(self, address): "Disconnects the client at ``address`` (ip:port)" return self.execute_command('CLIENT KILL', address) - def client_kill_filter(self, _id=None, _type=None, addr=None, skipme=None): + def client_kill_filter(self, _id=None, _type=None, addr=None, + skipme=None, laddr=None): """ Disconnects client(s) using a variety of filter options :param id: Kills a client by its unique ID field @@ -1218,6 +1219,7 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, skipme=None): 'master', 'slave' or 'pubsub' :param addr: Kills a client by its 'address:port' :param skipme: If True, then the client calling the command + :param laddr: Kills a cient by its 'local (bind) address:port' will not get killed even if it is identified by one of the filter options. If skipme is not provided, the server defaults to skipme=True """ @@ -1239,6 +1241,8 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, skipme=None): args.extend((b'ID', _id)) if addr is not None: args.extend((b'ADDR', addr)) + if laddr is not None: + args.extend((b'LADDR', laddr)) if not args: raise DataError("CLIENT KILL ... ... " " must specify at least one filter") diff --git a/tests/test_commands.py b/tests/test_commands.py index 362e7ab743..18f37d18ec 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -395,6 +395,26 @@ def test_client_list_after_client_setname(self, r): # we don't know which client ours will be assert 'redis_py_test' in [c['name'] for c in clients] + @skip_if_server_version_lt('6.2.0') + def test_client_kill_filter_by_laddr(self, r, r2): + r.client_setname('redis-py-c1') + r2.client_setname('redis-py-c2') + clients = [client for client in r.client_list() + if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + assert len(clients) == 2 + + clients_by_name = dict([(client.get('name'), client) + for client in clients]) + + client_2_addr = clients_by_name['redis-py-c2'].get('laddr') + resp = r.client_kill_filter(laddr=client_2_addr) + assert resp == 1 + + clients = [client for client in r.client_list() + if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + assert len(clients) == 1 + assert clients[0].get('name') == 'redis-py-c1' + @skip_if_server_version_lt('2.9.50') def test_client_pause(self, r): assert r.client_pause(1) From 01b96db203a651ff82598ad66685e487b74b8aac Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Sun, 25 Jul 2021 10:19:44 +0300 Subject: [PATCH 0113/1164] getdel (#1514) --- redis/client.py | 9 +++++++++ tests/test_commands.py | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/redis/client.py b/redis/client.py index ef3c82ca9f..fe6299f8cf 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1707,6 +1707,15 @@ def get(self, name): """ return self.execute_command('GET', name) + def getdel(self, name): + """ + Get the value at key ``name`` and delete the key. This command + is similar to GET, except for the fact that it also deletes + the key on success (if and only if the key's value type + is a string). + """ + return self.execute_command('GETDEL', name) + def getex(self, name, ex=None, px=None, exat=None, pxat=None, persist=False): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index 18f37d18ec..b83fe8ff46 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -757,6 +757,13 @@ def test_get_and_set(self, r): assert r.get('integer') == str(integer).encode() assert r.get('unicode_string').decode('utf-8') == unicode_string + @skip_if_server_version_lt('6.2.0') + def test_getdel(self, r): + assert r.getdel('a') is None + r.set('a', 1) + assert r.getdel('a') == b'1' + assert r.getdel('a') is None + @skip_if_server_version_lt('6.2.0') def test_getex(self, r): r.set('a', 1) From e7064450791b1cb20e1ff94f03385b2d02bb9ec7 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Sun, 25 Jul 2021 10:19:53 +0300 Subject: [PATCH 0114/1164] zrandmember (#1519) --- redis/client.py | 22 ++++++++++++++++++++++ tests/test_commands.py | 12 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/redis/client.py b/redis/client.py index fe6299f8cf..160f495c1f 100755 --- a/redis/client.py +++ b/redis/client.py @@ -2965,6 +2965,28 @@ def zpopmin(self, name, count=None): } return self.execute_command('ZPOPMIN', name, *args, **options) + def zrandmember(self, key, count=None, withscores=False): + """ + Return a random element from the sorted set value stored at key. + + ``count`` if the argument is positive, return an array of distinct + fields. If called with a negative count, the behavior changes and + the command is allowed to return the same field multiple times. + In this case, the number of returned fields is the absolute value + of the specified count. + + ``withscores`` The optional WITHSCORES modifier changes the reply so it + includes the respective scores of the randomly selected elements from + the sorted set. + """ + params = [] + if count is not None: + params.append(count) + if withscores: + params.append("WITHSCORES") + + return self.execute_command("ZRANDMEMBER", key, *params) + def bzpopmax(self, keys, timeout=0): """ ZPOPMAX a value off of the first non-empty sorted set diff --git a/tests/test_commands.py b/tests/test_commands.py index b83fe8ff46..3f0a82f721 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1537,6 +1537,18 @@ def test_zpopmin(self, r): assert r.zpopmin('a', count=2) == \ [(b'a2', 2), (b'a3', 3)] + @skip_if_server_version_lt('6.2.0') + def test_zrandemember(self, r): + r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) + assert r.zrandmember('a') is not None + assert len(r.zrandmember('a', 2)) == 2 + # with scores + assert len(r.zrandmember('a', 2, True)) == 4 + # without duplications + assert len(r.zrandmember('a', 10)) == 5 + # with duplications + assert len(r.zrandmember('a', -10)) == 10 + @skip_if_server_version_lt('4.9.0') def test_bzpopmax(self, r): r.zadd('a', {'a1': 1, 'a2': 2}) From c7ecb1d4aa68820e000f88c23f6ff3afd4166b8a Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 29 Jul 2021 14:46:50 +0300 Subject: [PATCH 0115/1164] LT and GT support for ZADD (#1509) Co-authored-by: malinaa96 <52569986+malinaa96@users.noreply.github.com> Co-authored-by: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> --- redis/client.py | 10 +++++++++- tests/test_commands.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 160f495c1f..ea36ef0ddc 100755 --- a/redis/client.py +++ b/redis/client.py @@ -2866,7 +2866,8 @@ def xtrim(self, name, maxlen, approximate=True): return self.execute_command('XTRIM', name, *pieces) # SORTED SET COMMANDS - def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False): + def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, + gt=None, lt=None): """ Set any number of element-name, score pairs to the key ``name``. Pairs are specified as a dict of element-names keys to score values. @@ -2897,6 +2898,9 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False): if incr and len(mapping) != 1: raise DataError("ZADD option 'incr' only works when passing a " "single element/score pair") + if nx is True and (gt is not None or lt is not None): + raise DataError("Only one of 'nx', 'lt', or 'gr' may be defined.") + pieces = [] options = {} if nx: @@ -2908,6 +2912,10 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False): if incr: pieces.append(b'INCR') options['as_score'] = True + if gt: + pieces.append(b'GT') + if lt: + pieces.append(b'LT') for pair in mapping.items(): pieces.append(pair[1]) pieces.append(pair[0]) diff --git a/tests/test_commands.py b/tests/test_commands.py index 3f0a82f721..62394a4412 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1462,6 +1462,23 @@ def test_zadd_incr_with_xx(self, r): # redis-py assert r.zadd('a', {'a1': 1}, xx=True, incr=True) is None + @skip_if_server_version_lt('6.2.0') + def test_zadd_gt_lt(self, r): + + for i in range(1, 20): + r.zadd('a', {'a%s' % i: i}) + assert r.zadd('a', {'a20': 5}, gt=3) == 1 + + for i in range(1, 20): + r.zadd('a', {'a%s' % i: i}) + assert r.zadd('a', {'a2': 5}, lt=1) == 0 + + # cannot use both nx and xx options + with pytest.raises(exceptions.DataError): + r.zadd('a', {'a15': 155}, nx=True, lt=True) + r.zadd('a', {'a15': 155}, nx=True, gt=True) + r.zadd('a', {'a15': 155}, lx=True, gt=True) + def test_zcard(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) assert r.zcard('a') == 3 From 53fe4d3eae75cabfb635eae5ae9197efa0bf86c3 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 29 Jul 2021 16:41:46 +0300 Subject: [PATCH 0116/1164] Zrangestore (#1521) --- redis/client.py | 9 +++++++++ tests/test_commands.py | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/redis/client.py b/redis/client.py index ea36ef0ddc..33b03746bc 100755 --- a/redis/client.py +++ b/redis/client.py @@ -3056,6 +3056,15 @@ def zrange(self, name, start, end, desc=False, withscores=False, } return self.execute_command(*pieces, **options) + def zrangestore(self, dest, name, start, end): + """ + Stores in ``dest`` the result of a range of values from sorted set + ``name`` between ``start`` and ``end`` sorted in ascending order. + + ``start`` and ``end`` can be negative, indicating the end of the range. + """ + return self.execute_command('ZRANGESTORE', dest, name, start, end) + def zrangebylex(self, name, min, max, start=None, num=None): """ Return the lexicographical range of values from sorted set ``name`` diff --git a/tests/test_commands.py b/tests/test_commands.py index 62394a4412..8411e5e7ef 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1605,6 +1605,16 @@ def test_zrange(self, r): assert r.zrange('a', 0, 1, withscores=True, score_cast_func=int) == \ [(b'a1', 1), (b'a2', 2)] + @skip_if_server_version_lt('6.2.0') + def test_zrangestore(self, r): + r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) + assert r.zrangestore('b', 'a', 0, 1) + assert r.zrange('b', 0, -1) == [b'a1', b'a2'] + assert r.zrangestore('b', 'a', 1, 2) + assert r.zrange('b', 0, -1) == [b'a2', b'a3'] + assert r.zrange('b', 0, -1, withscores=True) == \ + [(b'a2', 2), (b'a3', 3)] + @skip_if_server_version_lt('2.8.9') def test_zrangebylex(self, r): r.zadd('a', {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0}) From fc69bd65494d7ac8d08e16ff38e06936ab6dcc02 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 29 Jul 2021 16:41:55 +0300 Subject: [PATCH 0117/1164] zdiff and zdiffstore (#1518) --- redis/client.py | 22 ++++++++++++++++++++-- tests/test_commands.py | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/redis/client.py b/redis/client.py index 33b03746bc..4e4ccc69c2 100755 --- a/redis/client.py +++ b/redis/client.py @@ -595,8 +595,8 @@ class Redis: lambda r: r and set(r) or set() ), **string_keys_to_dict( - 'ZPOPMAX ZPOPMIN ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE', - zset_score_pairs + 'ZPOPMAX ZPOPMIN ZDIFF ZRANGE ZRANGEBYSCORE ZREVRANGE ' + 'ZREVRANGEBYSCORE', zset_score_pairs ), **string_keys_to_dict('BZPOPMIN BZPOPMAX', \ lambda r: @@ -2932,6 +2932,24 @@ def zcount(self, name, min, max): """ return self.execute_command('ZCOUNT', name, min, max) + def zdiff(self, keys, withscores=False): + """ + Returns the difference between the first and all successive input + sorted sets provided in ``keys``. + """ + pieces = [len(keys), *keys] + if withscores: + pieces.append("WITHSCORES") + return self.execute_command("ZDIFF", *pieces) + + def zdiffstore(self, dest, keys): + """ + Computes the difference between the first and all successive input + sorted sets provided in ``keys`` and stores the result in ``dest``. + """ + pieces = [len(keys), *keys] + return self.execute_command("ZDIFFSTORE", dest, *pieces) + def zincrby(self, name, amount, value): "Increment the score of ``value`` in sorted set ``name`` by ``amount``" return self.execute_command('ZINCRBY', name, amount, value) diff --git a/tests/test_commands.py b/tests/test_commands.py index 8411e5e7ef..9682603a9d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1491,6 +1491,21 @@ def test_zcount(self, r): assert r.zcount('a', 1, '(' + str(2)) == 1 assert r.zcount('a', 10, 20) == 0 + @skip_if_server_version_lt('6.2.0') + def test_zdiff(self, r): + r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) + r.zadd('b', {'a1': 1, 'a2': 2}) + assert r.zdiff(['a', 'b']) == [b'a3'] + assert r.zdiff(['a', 'b'], withscores=True) == [b'a3', b'3'] + + @skip_if_server_version_lt('6.2.0') + def test_zdiffstore(self, r): + r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) + r.zadd('b', {'a1': 1, 'a2': 2}) + assert r.zdiffstore("out", ['a', 'b']) + assert r.zrange("out", 0, -1) == [b'a3'] + assert r.zrange("out", 0, -1, withscores=True) == [(b'a3', 3.0)] + def test_zincrby(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) assert r.zincrby('a', 1, 'a2') == 3.0 From be26730e8136e6a27f397491d44fa723449023db Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 29 Jul 2021 20:32:29 +0300 Subject: [PATCH 0118/1164] ensuring we adhere to exlusive options for getex (#1531) --- redis/client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/redis/client.py b/redis/client.py index 4e4ccc69c2..a3da1c48ae 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1737,6 +1737,11 @@ def getex(self, name, ``persist`` remove the time to live associated with ``name``. """ + opset = set([ex, px, exat, pxat]) + if len(opset) > 2 or len(opset) > 1 and persist: + raise DataError("``ex``, ``px``, ``exat``, ``pxat``", + "and ``persist`` are mutually exclusive.") + pieces = [] # similar to set command if ex is not None: From e9c2e4574a06f240dc528d5ac20cdbeb5eb6564d Mon Sep 17 00:00:00 2001 From: Jonathan Herlin Date: Fri, 30 Jul 2021 07:27:03 +0200 Subject: [PATCH 0119/1164] Word was repeated in documentation (#1532) Small typo in documentation --- redis/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index a3da1c48ae..4ab1b1ee37 100755 --- a/redis/client.py +++ b/redis/client.py @@ -3322,7 +3322,7 @@ def hlen(self, name): def hset(self, name, key=None, value=None, mapping=None): """ Set ``key`` to ``value`` within hash ``name``, - ``mapping`` accepts a dict of key/value pairs that that will be + ``mapping`` accepts a dict of key/value pairs that will be added to hash ``name``. Returns the number of fields that were added. """ From 6f4ee2dee63171afa4c59742a3726594894916ae Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Sun, 1 Aug 2021 13:35:55 +0300 Subject: [PATCH 0120/1164] zinter (#1520) * zinter * change options in _zaggregate * skip for previous versions * flake8 * validate the aggregate value * invalid aggregation * invalid aggregation * change options to get Co-authored-by: Chayim --- redis/client.py | 46 +++++++++++++++++++++++++++++++++--------- tests/test_commands.py | 22 ++++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/redis/client.py b/redis/client.py index 4ab1b1ee37..0d8a5c2637 100755 --- a/redis/client.py +++ b/redis/client.py @@ -595,8 +595,8 @@ class Redis: lambda r: r and set(r) or set() ), **string_keys_to_dict( - 'ZPOPMAX ZPOPMIN ZDIFF ZRANGE ZRANGEBYSCORE ZREVRANGE ' - 'ZREVRANGEBYSCORE', zset_score_pairs + 'ZPOPMAX ZPOPMIN ZINTER ZDIFF ZRANGE ZRANGEBYSCORE ' + 'ZREVRANGE ZREVRANGEBYSCORE', zset_score_pairs ), **string_keys_to_dict('BZPOPMIN BZPOPMAX', \ lambda r: @@ -2959,11 +2959,28 @@ def zincrby(self, name, amount, value): "Increment the score of ``value`` in sorted set ``name`` by ``amount``" return self.execute_command('ZINCRBY', name, amount, value) + def zinter(self, keys, aggregate=None, withscores=False): + """ + Return the intersect of multiple sorted sets specified by ``keys``. + With the ``aggregate`` option, it is possible to specify how the + results of the union are aggregated. This option defaults to SUM, + where the score of an element is summed across the inputs where it + exists. When this option is set to either MIN or MAX, the resulting + set will contain the minimum or maximum score of an element across + the inputs where it exists. + """ + return self._zaggregate('ZINTER', None, keys, aggregate, + withscores=withscores) + def zinterstore(self, dest, keys, aggregate=None): """ - Intersect multiple sorted sets specified by ``keys`` into - a new sorted set, ``dest``. Scores in the destination will be - aggregated based on the ``aggregate``, or SUM if none is provided. + Intersect multiple sorted sets specified by ``keys`` into a new + sorted set, ``dest``. Scores in the destination will be aggregated + based on the ``aggregate``. This option defaults to SUM, where the + score of an element is summed across the inputs where it exists. + When this option is set to either MIN or MAX, the resulting set will + contain the minimum or maximum score of an element across the inputs + where it exists. """ return self._zaggregate('ZINTERSTORE', dest, keys, aggregate) @@ -3253,8 +3270,12 @@ def zunionstore(self, dest, keys, aggregate=None): """ return self._zaggregate('ZUNIONSTORE', dest, keys, aggregate) - def _zaggregate(self, command, dest, keys, aggregate=None): - pieces = [command, dest, len(keys)] + def _zaggregate(self, command, dest, keys, aggregate=None, + **options): + pieces = [command] + if dest is not None: + pieces.append(dest) + pieces.append(len(keys)) if isinstance(keys, dict): keys, weights = keys.keys(), keys.values() else: @@ -3264,9 +3285,14 @@ def _zaggregate(self, command, dest, keys, aggregate=None): pieces.append(b'WEIGHTS') pieces.extend(weights) if aggregate: - pieces.append(b'AGGREGATE') - pieces.append(aggregate) - return self.execute_command(*pieces) + if aggregate.upper() in ['SUM', 'MIN', 'MAX']: + pieces.append(b'AGGREGATE') + pieces.append(aggregate) + else: + raise DataError("aggregate can be sum, min or max.") + if options.get('withscores', False): + pieces.append(b'WITHSCORES') + return self.execute_command(*pieces, **options) # HYPERLOGLOG COMMANDS def pfadd(self, name, *values): diff --git a/tests/test_commands.py b/tests/test_commands.py index 9682603a9d..40c813d7a1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1519,6 +1519,28 @@ def test_zlexcount(self, r): assert r.zlexcount('a', '-', '+') == 7 assert r.zlexcount('a', '[b', '[f') == 5 + @skip_if_server_version_lt('6.2.0') + def test_zinter(self, r): + r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 1}) + r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zinter(['a', 'b', 'c']) == [b'a3', b'a1'] + # invalid aggregation + with pytest.raises(exceptions.DataError): + r.zinter(['a', 'b', 'c'], aggregate='foo', withscores=True) + # aggregate with SUM + assert r.zinter(['a', 'b', 'c'], withscores=True) \ + == [(b'a3', 8), (b'a1', 9)] + # aggregate with MAX + assert r.zinter(['a', 'b', 'c'], aggregate='MAX', withscores=True) \ + == [(b'a3', 5), (b'a1', 6)] + # aggregate with MIN + assert r.zinter(['a', 'b', 'c'], aggregate='MIN', withscores=True) \ + == [(b'a1', 1), (b'a3', 1)] + # with weights + assert r.zinter({'a': 1, 'b': 2, 'c': 3}, withscores=True) \ + == [(b'a3', 20), (b'a1', 23)] + def test_zinterstore_sum(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) From 3c0e1163c7afcc42fdcbbf22dd61ac869a6857ee Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 3 Aug 2021 18:06:01 +0300 Subject: [PATCH 0121/1164] exclusive gt and lt in zadd (#1533) * exclusive gt and lt in zadd * docs update --- redis/client.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 0d8a5c2637..514db536ed 100755 --- a/redis/client.py +++ b/redis/client.py @@ -2892,9 +2892,18 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, the existing score will be incremented by. When using this mode the return value of ZADD will be the new score of the element. + ``LT`` Only update existing elements if the new score is less than + the current score. This flag doesn't prevent adding new elements. + + ``GT`` Only update existing elements if the new score is greater than + the current score. This flag doesn't prevent adding new elements. + The return value of ZADD varies based on the mode specified. With no options, ZADD returns the number of new elements added to the sorted set. + + ``NX``, ``LT``, and ``GT`` are mutually exclusive options. + See: https://redis.io/commands/ZADD """ if not mapping: raise DataError("ZADD requires at least one element/score pair") @@ -2904,7 +2913,9 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, raise DataError("ZADD option 'incr' only works when passing a " "single element/score pair") if nx is True and (gt is not None or lt is not None): - raise DataError("Only one of 'nx', 'lt', or 'gr' may be defined.") + raise DataError("Only one of 'nx', 'lt', or 'gt' may be defined.") + if gt is not None and lt is not None: + raise DataError("Only one of 'gt' or 'lt' can be set.") pieces = [] options = {} From 2cfaac9949993386d9b2ac51cd676c709eb3d886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bielawski?= Date: Thu, 5 Aug 2021 06:57:06 +0100 Subject: [PATCH 0122/1164] Add trove classifier for Python 3.9 (#1535) --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 3fdea4e52d..a9c98491f8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,7 @@ classifiers = Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy From ec89d30815f0ac69ef8c59630fe3cf8314a3782d Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 5 Aug 2021 11:51:20 +0300 Subject: [PATCH 0123/1164] fix getex flaky tests --- tests/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 40c813d7a1..c44d2cbdd1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -775,7 +775,7 @@ def test_getex(self, r): assert r.ttl('a') == 6 expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) assert r.getex('a', pxat=expire_at) == b'1' - assert r.ttl('a') <= 60 + assert r.ttl('a') <= 61 assert r.getex('a', persist=True) == b'1' assert r.ttl('a') == -1 From 5240d60ff0636e7baaec2499c1c9018507578bf5 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 5 Aug 2021 11:59:22 +0300 Subject: [PATCH 0124/1164] Updating base testing docker to redis 6.2.5 (#1536) --- docker/base/Dockerfile | 2 +- tests/test_commands.py | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 003eac1f89..b6df3263c5 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1 +1 @@ -FROM redis:6.0.9-buster +FROM redis:6.2.5-buster diff --git a/tests/test_commands.py b/tests/test_commands.py index c44d2cbdd1..6f3f4d0880 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -407,13 +407,7 @@ def test_client_kill_filter_by_laddr(self, r, r2): for client in clients]) client_2_addr = clients_by_name['redis-py-c2'].get('laddr') - resp = r.client_kill_filter(laddr=client_2_addr) - assert resp == 1 - - clients = [client for client in r.client_list() - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] - assert len(clients) == 1 - assert clients[0].get('name') == 'redis-py-c1' + assert r.client_kill_filter(laddr=client_2_addr) @skip_if_server_version_lt('2.9.50') def test_client_pause(self, r): From 238f69e36e0ff5ac9b892706e3a5478b138069cd Mon Sep 17 00:00:00 2001 From: Gal Ben David Date: Thu, 5 Aug 2021 12:12:45 +0300 Subject: [PATCH 0125/1164] Add a count parameter to lpop/rpop for redis >= 6.2.0 (#1487) Co-authored-by: Chayim --- docker/base/Dockerfile | 2 +- redis/client.py | 30 ++++++++++++++++++++++++------ tests/test_commands.py | 16 ++++++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index b6df3263c5..60e4141e57 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1 +1 @@ -FROM redis:6.2.5-buster +FROM redis:6.2.5-buster \ No newline at end of file diff --git a/redis/client.py b/redis/client.py index 514db536ed..e5cd647fb8 100755 --- a/redis/client.py +++ b/redis/client.py @@ -2148,9 +2148,18 @@ def llen(self, name): "Return the length of the list ``name``" return self.execute_command('LLEN', name) - def lpop(self, name): - "Remove and return the first item of the list ``name``" - return self.execute_command('LPOP', name) + def lpop(self, name, count=None): + """ + Removes and returns the first elements of the list ``name``. + + By default, the command pops a single element from the beginning of + the list. When provided with the optional ``count`` argument, the reply + will consist of up to count elements, depending on the list's length. + """ + if count is not None: + return self.execute_command('LPOP', name, count) + else: + return self.execute_command('LPOP', name) def lpush(self, name, *values): "Push ``values`` onto the head of the list ``name``" @@ -2196,9 +2205,18 @@ def ltrim(self, name, start, end): """ return self.execute_command('LTRIM', name, start, end) - def rpop(self, name): - "Remove and return the last item of the list ``name``" - return self.execute_command('RPOP', name) + def rpop(self, name, count=None): + """ + Removes and returns the last elements of the list ``name``. + + By default, the command pops a single element from the end of the list. + When provided with the optional ``count`` argument, the reply will + consist of up to count elements, depending on the list's length. + """ + if count is not None: + return self.execute_command('RPOP', name, count) + else: + return self.execute_command('RPOP', name) def rpoplpush(self, src, dst): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index 6f3f4d0880..dbe00933e7 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1122,6 +1122,14 @@ def test_lpop(self, r): assert r.lpop('a') == b'3' assert r.lpop('a') is None + @skip_if_server_version_lt('6.2.0') + def test_lpop_count(self, r): + r.rpush('a', '1', '2', '3') + assert r.lpop('a', 2) == [b'1', b'2'] + assert r.lpop('a', 1) == [b'3'] + assert r.lpop('a') is None + assert r.lpop('a', 3) is None + def test_lpush(self, r): assert r.lpush('a', '1') == 1 assert r.lpush('a', '2') == 2 @@ -1171,6 +1179,14 @@ def test_rpop(self, r): assert r.rpop('a') == b'1' assert r.rpop('a') is None + @skip_if_server_version_lt('6.2.0') + def test_rpop_count(self, r): + r.rpush('a', '1', '2', '3') + assert r.rpop('a', 2) == [b'3', b'2'] + assert r.rpop('a', 1) == [b'1'] + assert r.rpop('a') is None + assert r.rpop('a', 3) is None + def test_rpoplpush(self, r): r.rpush('a', 'a1', 'a2', 'a3') r.rpush('b', 'b1', 'b2', 'b3') From 9c60670deea5c593e20204bbd5f172ccfcd1d9db Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 5 Aug 2021 12:24:40 +0300 Subject: [PATCH 0126/1164] add idle to xpending (#1523) --- redis/client.py | 42 +++++++++++++++++++++++++------------- tests/test_commands.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/redis/client.py b/redis/client.py index e5cd647fb8..995239eb12 100755 --- a/redis/client.py +++ b/redis/client.py @@ -2747,7 +2747,7 @@ def xpending(self, name, groupname): return self.execute_command('XPENDING', name, groupname) def xpending_range(self, name, groupname, min, max, count, - consumername=None): + consumername=None, idle=None): """ Returns information about pending messages, in a range. name: name of the stream. @@ -2756,21 +2756,35 @@ def xpending_range(self, name, groupname, min, max, count, max: maximum stream ID. count: number of messages to return consumername: name of a consumer to filter by (optional). + idle: available from version 6.2. filter entries by their + idle-time, given in milliseconds (optional). """ + if {min, max, count} == {None}: + if idle is not None or consumername is not None: + raise DataError("if XPENDING is provided with idle time" + " or consumername, it must be provided" + " with min, max and count parameters") + return self.xpending(name, groupname) + pieces = [name, groupname] - if min is not None or max is not None or count is not None: - if min is None or max is None or count is None: - raise DataError("XPENDING must be provided with min, max " - "and count parameters, or none of them. ") - if not isinstance(count, int) or count < -1: - raise DataError("XPENDING count must be a integer >= -1") - pieces.extend((min, max, str(count))) - if consumername is not None: - if min is None or max is None or count is None: - raise DataError("if XPENDING is provided with consumername," - " it must be provided with min, max and" - " count parameters") - pieces.append(consumername) + if min is None or max is None or count is None: + raise DataError("XPENDING must be provided with min, max " + "and count parameters, or none of them.") + # idle + try: + if int(idle) < 0: + raise DataError("XPENDING idle must be a integer >= 0") + pieces.extend(['IDLE', idle]) + except TypeError: + pass + # count + try: + if int(count) < 0: + raise DataError("XPENDING count must be a integer >= 0") + pieces.extend([min, max, count]) + except TypeError: + pass + return self.execute_command('XPENDING', *pieces, parse_detail=True) def xrange(self, name, min='-', max='+', count=None): diff --git a/tests/test_commands.py b/tests/test_commands.py index dbe00933e7..e92be622d0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2636,6 +2636,52 @@ def test_xpending_range(self, r): assert response[1]['message_id'] == m2 assert response[1]['consumer'] == consumer2.encode() + @skip_if_server_version_lt('6.2.0') + def test_xpending_range_idle(self, r): + stream = 'stream' + group = 'group' + consumer1 = 'consumer1' + consumer2 = 'consumer2' + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xgroup_create(stream, group, 0) + + # read 1 message from the group with each consumer + r.xreadgroup(group, consumer1, streams={stream: '>'}, count=1) + r.xreadgroup(group, consumer2, streams={stream: '>'}, count=1) + + response = r.xpending_range(stream, group, + min='-', max='+', count=5) + assert len(response) == 2 + response = r.xpending_range(stream, group, + min='-', max='+', count=5, idle=1000) + assert len(response) == 0 + + def test_xpending_range_negative(self, r): + stream = 'stream' + group = 'group' + with pytest.raises(redis.DataError): + r.xpending_range(stream, group, min='-', max='+', count=None) + with pytest.raises(ValueError): + r.xpending_range(stream, group, min='-', max='+', count="one") + with pytest.raises(redis.DataError): + r.xpending_range(stream, group, min='-', max='+', count=-1) + with pytest.raises(ValueError): + r.xpending_range(stream, group, min='-', max='+', count=5, + idle="one") + with pytest.raises(redis.exceptions.ResponseError): + r.xpending_range(stream, group, min='-', max='+', count=5, + idle=1.5) + with pytest.raises(redis.DataError): + r.xpending_range(stream, group, min='-', max='+', count=5, + idle=-1) + with pytest.raises(redis.DataError): + r.xpending_range(stream, group, min=None, max=None, count=None, + idle=0) + with pytest.raises(redis.DataError): + r.xpending_range(stream, group, min=None, max=None, count=None, + consumername=0) + @skip_if_server_version_lt('5.0.0') def test_xrange(self, r): stream = 'stream' From ba30d027a9a55a2ffd44dc8ca01d526b8705ab03 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 5 Aug 2021 12:25:25 +0300 Subject: [PATCH 0127/1164] xautoclaim (#1529) --- redis/client.py | 47 +++++++++++++++++++++++++++++++++++++++++ tests/test_commands.py | 48 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 995239eb12..019ff4f2a7 100755 --- a/redis/client.py +++ b/redis/client.py @@ -316,6 +316,12 @@ def parse_xclaim(response, **options): return parse_stream_list(response) +def parse_xautoclaim(response, **options): + if options.get('parse_justid', False): + return response[1] + return parse_stream_list(response[1]) + + def parse_xinfo_stream(response): data = pairs_to_dict(response, decode_keys=True) first = data['first-entry'] @@ -684,6 +690,7 @@ class Redis: 'SSCAN': parse_scan, 'TIME': lambda x: (int(x[0]), int(x[1])), 'XCLAIM': parse_xclaim, + 'XAUTOCLAIM': parse_xautoclaim, 'XGROUP CREATE': bool_ok, 'XGROUP DELCONSUMER': int, 'XGROUP DESTROY': bool, @@ -2601,6 +2608,46 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True, pieces.extend(pair) return self.execute_command('XADD', name, *pieces) + def xautoclaim(self, name, groupname, consumername, min_idle_time, + start_id=0, count=None, justid=False): + """ + Transfers ownership of pending stream entries that match the specified + criteria. Conceptually, equivalent to calling XPENDING and then XCLAIM, + but provides a more straightforward way to deal with message delivery + failures via SCAN-like semantics. + name: name of the stream. + groupname: name of the consumer group. + consumername: name of a consumer that claims the message. + min_idle_time: filter messages that were idle less than this amount of + milliseconds. + start_id: filter messages with equal or greater ID. + count: optional integer, upper limit of the number of entries that the + command attempts to claim. Set to 100 by default. + justid: optional boolean, false by default. Return just an array of IDs + of messages successfully claimed, without returning the actual message + """ + try: + if int(min_idle_time) < 0: + raise DataError("XAUTOCLAIM min_idle_time must be a non" + "negative integer") + except TypeError: + pass + + kwargs = {} + pieces = [name, groupname, consumername, min_idle_time, start_id] + + try: + if int(count) < 0: + raise DataError("XPENDING count must be a integer >= 0") + pieces.extend([b'COUNT', count]) + except TypeError: + pass + if justid: + pieces.append(b'JUSTID') + kwargs['parse_justid'] = True + + return self.execute_command('XAUTOCLAIM', *pieces, **kwargs) + def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, idle=None, time=None, retrycount=None, force=False, justid=False): diff --git a/tests/test_commands.py b/tests/test_commands.py index e92be622d0..60e667d7ad 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2374,13 +2374,59 @@ def test_xadd_nomkstream(self, r): r.xadd(stream, {'some': 'other'}, nomkstream=True) assert r.xlen(stream) == 3 + @skip_if_server_version_lt('6.2.0') + def test_xautoclaim(self, r): + stream = 'stream' + group = 'group' + consumer1 = 'consumer1' + consumer2 = 'consumer2' + + message_id1 = r.xadd(stream, {'john': 'wick'}) + message_id2 = r.xadd(stream, {'johny': 'deff'}) + message = get_stream_message(r, stream, message_id1) + r.xgroup_create(stream, group, 0) + + # trying to claim a message that isn't already pending doesn't + # do anything + response = r.xautoclaim(stream, group, consumer2, min_idle_time=0) + assert response == [] + + # read the group as consumer1 to initially claim the messages + r.xreadgroup(group, consumer1, streams={stream: '>'}) + + # claim one message as consumer2 + response = r.xautoclaim(stream, group, consumer2, + min_idle_time=0, count=1) + assert response == [message] + + # reclaim the messages as consumer1, but use the justid argument + # which only returns message ids + assert r.xautoclaim(stream, group, consumer1, min_idle_time=0, + start_id=0, justid=True) == \ + [message_id1, message_id2] + assert r.xautoclaim(stream, group, consumer1, min_idle_time=0, + start_id=message_id2, justid=True) == \ + [message_id2] + + @skip_if_server_version_lt('6.2.0') + def test_xautoclaim_negative(self, r): + stream = 'stream' + group = 'group' + consumer = 'consumer' + with pytest.raises(redis.DataError): + r.xautoclaim(stream, group, consumer, min_idle_time=-1) + with pytest.raises(ValueError): + r.xautoclaim(stream, group, consumer, min_idle_time="wrong") + with pytest.raises(redis.DataError): + r.xautoclaim(stream, group, consumer, min_idle_time=0, + count=-1) + @skip_if_server_version_lt('5.0.0') def test_xclaim(self, r): stream = 'stream' group = 'group' consumer1 = 'consumer1' consumer2 = 'consumer2' - message_id = r.xadd(stream, {'john': 'wick'}) message = get_stream_message(r, stream, message_id) r.xgroup_create(stream, group, 0) From 8c4fc802ed03cbebeb15686755e792bc179750e7 Mon Sep 17 00:00:00 2001 From: Binbin Date: Sun, 8 Aug 2021 17:26:30 +0800 Subject: [PATCH 0128/1164] Fix some typos. (#1496) --- CHANGES | 6 +++--- redis/client.py | 12 ++++++------ redis/sentinel.py | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGES b/CHANGES index f4974b8299..4c2ba40f7a 100644 --- a/CHANGES +++ b/CHANGES @@ -400,7 +400,7 @@ * 2.10.2 * Added support for Hiredis's new bytearray support. Thanks https://github.com/tzickel - * POSSIBLE BACKWARDS INCOMPATBLE CHANGE: Fixed a possible race condition + * POSSIBLE BACKWARDS INCOMPATIBLE CHANGE: Fixed a possible race condition when multiple threads share the same Lock instance with a timeout. Lock tokens are now stored in thread local storage by default. If you have code that acquires a lock in one thread and passes that lock instance to @@ -455,12 +455,12 @@ * Fixed Sentinel state parsing on Python 3. * Added support for SENTINEL MONITOR, SENTINEL REMOVE, and SENTINEL SET commands. Thanks Greg Murphy. - * INFO ouput that doesn't follow the "key:value" format will now be + * INFO output that doesn't follow the "key:value" format will now be appended to a key named "__raw__" in the INFO dictionary. Thanks Pedro Larroy. * The "vagrant" directory contains a complete vagrant environment for redis-py developers. The environment runs a Redis master, a Redis slave, - and 3 Sentinels. Future iterations of the test sutie will incorporate + and 3 Sentinels. Future iterations of the test suite will incorporate more integration style tests, ensuring things like failover happen correctly. * It's now possible to create connection pool instances from a URL. diff --git a/redis/client.py b/redis/client.py index 019ff4f2a7..5d1cd2c306 100755 --- a/redis/client.py +++ b/redis/client.py @@ -469,7 +469,7 @@ def parse_georadius_generic(response, **options): 'withhash': int } - # zip all output results with each casting functino to get + # zip all output results with each casting function to get # the properly native Python value. f = [lambda x: x] f += [cast[o] for o in ['withdist', 'withhash', 'withcoord'] if options[o]] @@ -541,7 +541,7 @@ def parse_client_info(value): key, value = info.split("=") client_info[key] = value - # Those fields are definded as int in networking.c + # Those fields are defined as int in networking.c for int_key in {"id", "age", "idle", "db", "sub", "psub", "multi", "qbuf", "qbuf-free", "obl", "argv-mem", "oll", "omem", "tot-mem"}: @@ -1592,7 +1592,7 @@ def append(self, key, value): def bitcount(self, key, start=None, end=None): """ Returns the count of set bits in the value of ``key``. Optional - ``start`` and ``end`` paramaters indicate which bytes to consider + ``start`` and ``end`` parameters indicate which bytes to consider """ params = [key] if start is not None and end is not None: @@ -1620,7 +1620,7 @@ def bitop(self, operation, dest, *keys): def bitpos(self, key, bit, start=None, end=None): """ Return the position of the first bit set to 1 or 0 in a string. - ``start`` and ``end`` difines search range. The range is interpreted + ``start`` and ``end`` defines search range. The range is interpreted as a range of bytes and not a range of bits, so start=0 and end=2 means to look at the first three bytes. """ @@ -2576,7 +2576,7 @@ def xack(self, name, groupname, *ids): Acknowledges the successful processing of one or more messages. name: name of the stream. groupname: name of the consumer group. - *ids: message ids to acknowlege. + *ids: message ids to acknowledge. """ return self.execute_command('XACK', name, groupname, *ids) @@ -4510,7 +4510,7 @@ def __call__(self, keys=[], args=[], client=None): try: return client.evalsha(self.sha, len(keys), *args) except NoScriptError: - # Maybe the client is pointed to a differnet server than the client + # Maybe the client is pointed to a different server than the client # that created this instance? # Overwrite the sha just in case there was a discrepancy. self.sha = client.script_load(self.script) diff --git a/redis/sentinel.py b/redis/sentinel.py index b9d77f1c6e..f39e2e7d1a 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -245,7 +245,7 @@ def master_for(self, service_name, redis_class=Redis, Returns a redis client instance for the ``service_name`` master. A :py:class:`~redis.sentinel.SentinelConnectionPool` class is - used to retrive the master's address before establishing a new + used to retrieve the master's address before establishing a new connection. NOTE: If the master's address has changed, any cached connections to @@ -274,7 +274,7 @@ def slave_for(self, service_name, redis_class=Redis, """ Returns redis client instance for the ``service_name`` slave(s). - A SentinelConnectionPool class is used to retrive the slave's + A SentinelConnectionPool class is used to retrieve the slave's address before establishing a new connection. By default clients will be a :py:class:`~redis.Redis` instance. From 2789f08dfb7192f112aa0fc8cec6738dfb1b3a08 Mon Sep 17 00:00:00 2001 From: Jiekun <2014bduck@gmail.com> Date: Sun, 8 Aug 2021 17:36:17 +0800 Subject: [PATCH 0129/1164] Added GET argument to SET command (#1412) --- redis/client.py | 32 +++++++++++++++++++++++++++++--- tests/test_commands.py | 8 ++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/redis/client.py b/redis/client.py index 5d1cd2c306..3c3ab7fd19 100755 --- a/redis/client.py +++ b/redis/client.py @@ -555,6 +555,20 @@ def parse_module_result(response): return True +def parse_set_result(response, **options): + """ + Handle SET result since GET argument is available since Redis 6.2. + Parsing SET result into: + - BOOL + - String when GET argument is used + """ + if options.get('get'): + # Redis will return a getCommand result. + # See `setGenericCommand` in t_string.c + return response + return response and str_if_bytes(response) == 'OK' + + class Redis: """ Implementation of the Redis protocol. @@ -683,7 +697,7 @@ class Redis: 'SENTINEL SENTINELS': parse_sentinel_slaves_and_sentinels, 'SENTINEL SET': bool_ok, 'SENTINEL SLAVES': parse_sentinel_slaves_and_sentinels, - 'SET': lambda r: r and str_if_bytes(r) == 'OK', + 'SET': parse_set_result, 'SLOWLOG GET': parse_slowlog_get, 'SLOWLOG LEN': int, 'SLOWLOG RESET': bool_ok, @@ -1804,6 +1818,9 @@ def getset(self, name, value): """ Sets the value at key ``name`` to ``value`` and returns the old value at key ``name`` atomically. + + As per Redis 6.2, GETSET is considered deprecated. + Please use SET with GET parameter in new code. """ return self.execute_command('GETSET', name, value) @@ -1964,7 +1981,7 @@ def restore(self, name, ttl, value, replace=False, absttl=False): return self.execute_command('RESTORE', *params) def set(self, name, value, - ex=None, px=None, nx=False, xx=False, keepttl=False): + ex=None, px=None, nx=False, xx=False, keepttl=False, get=False): """ Set the value at key ``name`` to ``value`` @@ -1980,8 +1997,13 @@ def set(self, name, value, ``keepttl`` if True, retain the time to live associated with the key. (Available since Redis 6.0) + + ``get`` if True, set the value at key ``name`` to ``value`` and return + the old value stored at key, or None when key did not exist. + (Available since Redis 6.2) """ pieces = [name, value] + options = {} if ex is not None: pieces.append('EX') if isinstance(ex, datetime.timedelta): @@ -2001,7 +2023,11 @@ def set(self, name, value, if keepttl: pieces.append('KEEPTTL') - return self.execute_command('SET', *pieces) + if get: + pieces.append('GET') + options["get"] = True + + return self.execute_command('SET', *pieces, **options) def __setitem__(self, name, value): self.set(name, value) diff --git a/tests/test_commands.py b/tests/test_commands.py index 60e667d7ad..76322d70f1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1006,6 +1006,14 @@ def test_set_keepttl(self, r): assert r.get('a') == b'2' assert 0 < r.ttl('a') <= 10 + @skip_if_server_version_lt('6.2.0') + def test_set_get(self, r): + assert r.set('a', 'True', get=True) is None + assert r.set('a', 'True', get=True) == b'True' + assert r.set('a', 'foo') is True + assert r.set('a', 'bar', get=True) == b'foo' + assert r.get('a') == b'bar' + def test_setex(self, r): assert r.setex('a', 60, '1') assert r['a'] == b'1' From 37e7c093cbeaf7133d5dd7189563d5c15da8d12b Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 15 Aug 2021 13:44:41 +0300 Subject: [PATCH 0130/1164] implementing the LMOVE and BLMOVE commands (#1504) --- redis/client.py | 21 +++++++++++++++++++-- tests/test_commands.py | 12 ++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/redis/client.py b/redis/client.py index 3c3ab7fd19..a80012a989 100755 --- a/redis/client.py +++ b/redis/client.py @@ -581,8 +581,8 @@ class Redis: """ RESPONSE_CALLBACKS = { **string_keys_to_dict( - 'AUTH COPY EXPIRE EXPIREAT HEXISTS HMSET MOVE MSETNX PERSIST ' - 'PSETEX RENAMENX SISMEMBER SMOVE SETEX SETNX', + 'AUTH COPY EXPIRE EXPIREAT HEXISTS HMSET LMOVE BLMOVE MOVE ' + 'MSETNX PERSIST PSETEX RENAMENX SISMEMBER SMOVE SETEX SETNX', bool ), **string_keys_to_dict( @@ -1851,6 +1851,23 @@ def keys(self, pattern='*'): "Returns a list of keys matching ``pattern``" return self.execute_command('KEYS', pattern) + def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): + """ + Atomically returns and removes the first/last element of a list, + pushing it as the first/last element on the destination list. + Returns the element being popped and pushed. + """ + params = [first_list, second_list, src, dest] + return self.execute_command("LMOVE", *params) + + def blmove(self, first_list, second_list, timeout, + src="LEFT", dest="RIGHT"): + """ + Blocking version of lmove. + """ + params = [first_list, second_list, src, dest, timeout] + return self.execute_command("BLMOVE", *params) + def mget(self, keys, *args): """ Returns a list of values ordered identically to ``keys`` diff --git a/tests/test_commands.py b/tests/test_commands.py index 76322d70f1..736aec95bd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -849,6 +849,18 @@ def test_mget(self, r): r['c'] = '3' assert r.mget('a', 'other', 'b', 'c') == [b'1', None, b'2', b'3'] + @skip_if_server_version_lt('6.2.0') + def test_lmove(self, r): + r.rpush('a', 'one', 'two', 'three', 'four') + assert r.lmove('a', 'b') + assert r.lmove('a', 'b', 'right', 'left') + + @skip_if_server_version_lt('6.2.0') + def test_blmove(self, r): + r.rpush('a', 'one', 'two', 'three', 'four') + assert r.blmove('a', 'b', 5) + assert r.blmove('a', 'b', 1, 'RIGHT', 'LEFT') + def test_mset(self, r): d = {'a': b'1', 'b': b'2', 'c': b'3'} assert r.mset(d) From e498182b8e208911ea2454838109fb1249b83c5c Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 15 Aug 2021 13:46:01 +0300 Subject: [PATCH 0131/1164] MINID and LIMIT support for xtrim (#1508) --- redis/client.py | 24 +++++++++++++++++++++--- tests/test_commands.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/redis/client.py b/redis/client.py index a80012a989..2d47574908 100755 --- a/redis/client.py +++ b/redis/client.py @@ -2979,17 +2979,35 @@ def xrevrange(self, name, max='+', min='-', count=None): return self.execute_command('XREVRANGE', name, *pieces) - def xtrim(self, name, maxlen, approximate=True): + def xtrim(self, name, maxlen=None, approximate=True, minid=None, + limit=None): """ Trims old messages from a stream. name: name of the stream. maxlen: truncate old stream messages beyond this size approximate: actual stream length may be slightly more than maxlen + minin: the minimum id in the stream to query + limit: specifies the maximum number of entries to retrieve """ - pieces = [b'MAXLEN'] + pieces = [] + if maxlen is not None and minid is not None: + raise DataError("Only one of ```maxlen``` or ```minid```", + "may be specified") + + if maxlen is not None: + pieces.append(b'MAXLEN') + if minid is not None: + pieces.append(b'MINID') if approximate: pieces.append(b'~') - pieces.append(maxlen) + if maxlen is not None: + pieces.append(maxlen) + if minid is not None: + pieces.append(minid) + if limit is not None: + pieces.append(b"LIMIT") + pieces.append(limit) + return self.execute_command('XTRIM', name, *pieces) # SORTED SET COMMANDS diff --git a/tests/test_commands.py b/tests/test_commands.py index 736aec95bd..dff48b7eb8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2927,6 +2927,47 @@ def test_xtrim(self, r): # 1 message is trimmed assert r.xtrim(stream, 3, approximate=False) == 1 + @skip_if_server_version_lt('6.2.4') + def test_xtrim_minlen_and_length_args(self, r): + stream = 'stream' + + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + + # Future self: No limits without approximate, according to the api + with pytest.raises(redis.ResponseError): + assert r.xtrim(stream, 3, approximate=False, limit=2) + + # maxlen with a limit + assert r.xtrim(stream, 3, approximate=True, limit=2) == 0 + r.delete(stream) + + with pytest.raises(redis.DataError): + assert r.xtrim(stream, maxlen=3, minid="sometestvalue") + + # minid with a limit + m1 = r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + assert r.xtrim(stream, None, approximate=True, minid=m1, limit=3) == 0 + + # pure minid + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + m4 = r.xadd(stream, {'foo': 'bar'}) + assert r.xtrim(stream, None, approximate=False, minid=m4) == 7 + + # minid approximate + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + m3 = r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + assert r.xtrim(stream, None, approximate=True, minid=m3) == 0 + def test_bitfield_operations(self, r): # comments show affected bits bf = r.bitfield('a') From 161774be431f2de3c44037698ac311735d559636 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 15 Aug 2021 14:07:44 +0300 Subject: [PATCH 0132/1164] Adding support for CLIENT LIST with ID (#1505) --- redis/client.py | 15 +++++++++------ tests/test_commands.py | 8 ++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/redis/client.py b/redis/client.py index 2d47574908..28e3ac11b4 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1276,7 +1276,7 @@ def client_info(self): """ return self.execute_command('CLIENT INFO') - def client_list(self, _type=None): + def client_list(self, _type=None, client_id=None): """ Returns a list of currently connected clients. If type of client specified, only that type will be returned. @@ -1284,13 +1284,18 @@ def client_list(self, _type=None): replica, pubsub) """ "Returns a list of currently connected clients" + args = [] if _type is not None: client_types = ('normal', 'master', 'replica', 'pubsub') if str(_type).lower() not in client_types: raise DataError("CLIENT LIST _type must be one of %r" % ( client_types,)) - return self.execute_command('CLIENT LIST', b'TYPE', _type) - return self.execute_command('CLIENT LIST') + args.append(b'TYPE') + args.append(_type) + if client_id is not None: + args.append(b"ID") + args.append(client_id) + return self.execute_command('CLIENT LIST', *args) def client_getname(self): "Returns the current connection name" @@ -3053,9 +3058,7 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, raise DataError("ZADD option 'incr' only works when passing a " "single element/score pair") if nx is True and (gt is not None or lt is not None): - raise DataError("Only one of 'nx', 'lt', or 'gt' may be defined.") - if gt is not None and lt is not None: - raise DataError("Only one of 'gt' or 'lt' can be set.") + raise DataError("Only one of 'nx', 'lt', or 'gr' may be defined.") pieces = [] options = {} diff --git a/tests/test_commands.py b/tests/test_commands.py index dff48b7eb8..9a618aab1c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -295,6 +295,14 @@ def test_client_list_type(self, r): clients = r.client_list(_type=client_type) assert isinstance(clients, list) + @skip_if_server_version_lt('6.2.0') + def test_client_list_client_id(self, r): + clients = r.client_list() + client_id = clients[0]['id'] + clients = r.client_list(client_id=client_id) + assert len(clients) == 1 + assert 'addr' in clients[0] + @skip_if_server_version_lt('5.0.0') def test_client_id(self, r): assert r.client_id() > 0 From 8c82e429224e0fb7f1739f741e63f23126e3c088 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Sun, 15 Aug 2021 14:13:28 +0300 Subject: [PATCH 0133/1164] Zunion (#1522) * zinter * change options in _zaggregate * skip for previous versions * add client function * validate the aggregate value * change options to get * add more aggregate tests * add weights guidance --- redis/client.py | 12 +++++++++++- tests/test_commands.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 28e3ac11b4..f50c27ef4b 100755 --- a/redis/client.py +++ b/redis/client.py @@ -615,7 +615,7 @@ class Redis: lambda r: r and set(r) or set() ), **string_keys_to_dict( - 'ZPOPMAX ZPOPMIN ZINTER ZDIFF ZRANGE ZRANGEBYSCORE ' + 'ZPOPMAX ZPOPMIN ZINTER ZDIFF ZUNION ZRANGE ZRANGEBYSCORE ' 'ZREVRANGE ZREVRANGEBYSCORE', zset_score_pairs ), **string_keys_to_dict('BZPOPMIN BZPOPMAX', \ @@ -3416,6 +3416,16 @@ def zscore(self, name, value): "Return the score of element ``value`` in sorted set ``name``" return self.execute_command('ZSCORE', name, value) + def zunion(self, keys, aggregate=None, withscores=False): + """ + Return the union of multiple sorted sets specified by ``keys``. + ``keys`` can be provided as dictionary of keys and their weights. + Scores will be aggregated based on the ``aggregate``, or SUM if + none is provided. + """ + return self._zaggregate('ZUNION', None, keys, aggregate, + withscores=withscores) + def zunionstore(self, dest, keys, aggregate=None): """ Union multiple sorted sets specified by ``keys`` into diff --git a/tests/test_commands.py b/tests/test_commands.py index 9a618aab1c..3c87dd9dd6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1813,6 +1813,26 @@ def test_zscore(self, r): assert r.zscore('a', 'a2') == 2.0 assert r.zscore('a', 'a4') is None + @skip_if_server_version_lt('6.2.0') + def test_zunion(self, r): + r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) + r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) + # sum + assert r.zunion(['a', 'b', 'c']) == \ + [b'a2', b'a4', b'a3', b'a1'] + assert r.zunion(['a', 'b', 'c'], withscores=True) == \ + [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + # max + assert r.zunion(['a', 'b', 'c'], aggregate='MAX', withscores=True)\ + == [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + # min + assert r.zunion(['a', 'b', 'c'], aggregate='MIN', withscores=True)\ + == [(b'a1', 1), (b'a2', 1), (b'a3', 1), (b'a4', 4)] + # with weight + assert r.zunion({'a': 1, 'b': 2, 'c': 3}, withscores=True)\ + == [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] + def test_zunionstore_sum(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) From 8ea26c4f5c82a3fde5dd2fb209d0c8644714626d Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 18 Aug 2021 09:51:57 +0300 Subject: [PATCH 0134/1164] Migrating commands to a mixin (#1534) * Moving redis commands to a mixin This patterns allows for the reuse of these commands across connection types, including modules that are based on this client * splitting sentinel specific commands into the respective mixin --- redis/client.py | 2855 +----------------------------------------- redis/commands.py | 2995 +++++++++++++++++++++++++++++++++++++++++++++ redis/sentinel.py | 3 +- 3 files changed, 3007 insertions(+), 2846 deletions(-) create mode 100644 redis/commands.py diff --git a/redis/client.py b/redis/client.py index f50c27ef4b..741c2d0226 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1,25 +1,27 @@ from itertools import chain import datetime -import warnings -import time -import threading -import time as mod_time -import re import hashlib +import re +import threading +import time +import warnings +from redis.commands import ( + list_or_args, + Commands +) from redis.connection import (ConnectionPool, UnixDomainSocketConnection, SSLConnection) from redis.lock import Lock from redis.exceptions import ( ConnectionError, - DataError, ExecAbortError, + ModuleError, NoScriptError, PubSubError, RedisError, ResponseError, TimeoutError, WatchError, - ModuleError, ) from redis.utils import safe_str, str_if_bytes @@ -27,23 +29,6 @@ EMPTY_RESPONSE = 'EMPTY_RESPONSE' -def list_or_args(keys, args): - # returns a single new list combining keys and args - try: - iter(keys) - # a string or bytes instance can be iterated, but indicates - # keys wasn't passed as a list - if isinstance(keys, (bytes, str)): - keys = [keys] - else: - keys = list(keys) - except TypeError: - keys = [keys] - if args: - keys.extend(args) - return keys - - def timestamp_to_datetime(response): "Converts a unix timestamp to a Python datetime object" if not response: @@ -569,7 +554,7 @@ def parse_set_result(response, **options): return response and str_if_bytes(response) == 'OK' -class Redis: +class Redis(Commands, object): """ Implementation of the Redis protocol. @@ -986,2826 +971,6 @@ def parse_response(self, connection, command_name, **options): return self.response_callbacks[command_name](response, **options) return response - # SERVER INFORMATION - - # ACL methods - def acl_cat(self, category=None): - """ - Returns a list of categories or commands within a category. - - If ``category`` is not supplied, returns a list of all categories. - If ``category`` is supplied, returns a list of all commands within - that category. - """ - pieces = [category] if category else [] - return self.execute_command('ACL CAT', *pieces) - - def acl_deluser(self, username): - "Delete the ACL for the specified ``username``" - return self.execute_command('ACL DELUSER', username) - - def acl_genpass(self): - "Generate a random password value" - return self.execute_command('ACL GENPASS') - - def acl_getuser(self, username): - """ - Get the ACL details for the specified ``username``. - - If ``username`` does not exist, return None - """ - return self.execute_command('ACL GETUSER', username) - - def acl_list(self): - "Return a list of all ACLs on the server" - return self.execute_command('ACL LIST') - - def acl_log(self, count=None): - """ - Get ACL logs as a list. - :param int count: Get logs[0:count]. - :rtype: List. - """ - args = [] - if count is not None: - if not isinstance(count, int): - raise DataError('ACL LOG count must be an ' - 'integer') - args.append(count) - - return self.execute_command('ACL LOG', *args) - - def acl_log_reset(self): - """ - Reset ACL logs. - :rtype: Boolean. - """ - args = [b'RESET'] - return self.execute_command('ACL LOG', *args) - - def acl_load(self): - """ - Load ACL rules from the configured ``aclfile``. - - Note that the server must be configured with the ``aclfile`` - directive to be able to load ACL rules from an aclfile. - """ - return self.execute_command('ACL LOAD') - - def acl_save(self): - """ - Save ACL rules to the configured ``aclfile``. - - Note that the server must be configured with the ``aclfile`` - directive to be able to save ACL rules to an aclfile. - """ - return self.execute_command('ACL SAVE') - - def acl_setuser(self, username, enabled=False, nopass=False, - passwords=None, hashed_passwords=None, categories=None, - commands=None, keys=None, reset=False, reset_keys=False, - reset_passwords=False): - """ - Create or update an ACL user. - - Create or update the ACL for ``username``. If the user already exists, - the existing ACL is completely overwritten and replaced with the - specified values. - - ``enabled`` is a boolean indicating whether the user should be allowed - to authenticate or not. Defaults to ``False``. - - ``nopass`` is a boolean indicating whether the can authenticate without - a password. This cannot be True if ``passwords`` are also specified. - - ``passwords`` if specified is a list of plain text passwords - to add to or remove from the user. Each password must be prefixed with - a '+' to add or a '-' to remove. For convenience, the value of - ``passwords`` can be a simple prefixed string when adding or - removing a single password. - - ``hashed_passwords`` if specified is a list of SHA-256 hashed passwords - to add to or remove from the user. Each hashed password must be - prefixed with a '+' to add or a '-' to remove. For convenience, - the value of ``hashed_passwords`` can be a simple prefixed string when - adding or removing a single password. - - ``categories`` if specified is a list of strings representing category - permissions. Each string must be prefixed with either a '+' to add the - category permission or a '-' to remove the category permission. - - ``commands`` if specified is a list of strings representing command - permissions. Each string must be prefixed with either a '+' to add the - command permission or a '-' to remove the command permission. - - ``keys`` if specified is a list of key patterns to grant the user - access to. Keys patterns allow '*' to support wildcard matching. For - example, '*' grants access to all keys while 'cache:*' grants access - to all keys that are prefixed with 'cache:'. ``keys`` should not be - prefixed with a '~'. - - ``reset`` is a boolean indicating whether the user should be fully - reset prior to applying the new ACL. Setting this to True will - remove all existing passwords, flags and privileges from the user and - then apply the specified rules. If this is False, the user's existing - passwords, flags and privileges will be kept and any new specified - rules will be applied on top. - - ``reset_keys`` is a boolean indicating whether the user's key - permissions should be reset prior to applying any new key permissions - specified in ``keys``. If this is False, the user's existing - key permissions will be kept and any new specified key permissions - will be applied on top. - - ``reset_passwords`` is a boolean indicating whether to remove all - existing passwords and the 'nopass' flag from the user prior to - applying any new passwords specified in 'passwords' or - 'hashed_passwords'. If this is False, the user's existing passwords - and 'nopass' status will be kept and any new specified passwords - or hashed_passwords will be applied on top. - """ - encoder = self.connection_pool.get_encoder() - pieces = [username] - - if reset: - pieces.append(b'reset') - - if reset_keys: - pieces.append(b'resetkeys') - - if reset_passwords: - pieces.append(b'resetpass') - - if enabled: - pieces.append(b'on') - else: - pieces.append(b'off') - - if (passwords or hashed_passwords) and nopass: - raise DataError('Cannot set \'nopass\' and supply ' - '\'passwords\' or \'hashed_passwords\'') - - if passwords: - # as most users will have only one password, allow remove_passwords - # to be specified as a simple string or a list - passwords = list_or_args(passwords, []) - for i, password in enumerate(passwords): - password = encoder.encode(password) - if password.startswith(b'+'): - pieces.append(b'>%s' % password[1:]) - elif password.startswith(b'-'): - pieces.append(b'<%s' % password[1:]) - else: - raise DataError('Password %d must be prefixeed with a ' - '"+" to add or a "-" to remove' % i) - - if hashed_passwords: - # as most users will have only one password, allow remove_passwords - # to be specified as a simple string or a list - hashed_passwords = list_or_args(hashed_passwords, []) - for i, hashed_password in enumerate(hashed_passwords): - hashed_password = encoder.encode(hashed_password) - if hashed_password.startswith(b'+'): - pieces.append(b'#%s' % hashed_password[1:]) - elif hashed_password.startswith(b'-'): - pieces.append(b'!%s' % hashed_password[1:]) - else: - raise DataError('Hashed %d password must be prefixeed ' - 'with a "+" to add or a "-" to remove' % i) - - if nopass: - pieces.append(b'nopass') - - if categories: - for category in categories: - category = encoder.encode(category) - # categories can be prefixed with one of (+@, +, -@, -) - if category.startswith(b'+@'): - pieces.append(category) - elif category.startswith(b'+'): - pieces.append(b'+@%s' % category[1:]) - elif category.startswith(b'-@'): - pieces.append(category) - elif category.startswith(b'-'): - pieces.append(b'-@%s' % category[1:]) - else: - raise DataError('Category "%s" must be prefixed with ' - '"+" or "-"' - % encoder.decode(category, force=True)) - if commands: - for cmd in commands: - cmd = encoder.encode(cmd) - if not cmd.startswith(b'+') and not cmd.startswith(b'-'): - raise DataError('Command "%s" must be prefixed with ' - '"+" or "-"' - % encoder.decode(cmd, force=True)) - pieces.append(cmd) - - if keys: - for key in keys: - key = encoder.encode(key) - pieces.append(b'~%s' % key) - - return self.execute_command('ACL SETUSER', *pieces) - - def acl_users(self): - "Returns a list of all registered users on the server." - return self.execute_command('ACL USERS') - - def acl_whoami(self): - "Get the username for the current connection" - return self.execute_command('ACL WHOAMI') - - def bgrewriteaof(self): - "Tell the Redis server to rewrite the AOF file from data in memory." - return self.execute_command('BGREWRITEAOF') - - def bgsave(self): - """ - Tell the Redis server to save its data to disk. Unlike save(), - this method is asynchronous and returns immediately. - """ - return self.execute_command('BGSAVE') - - def client_kill(self, address): - "Disconnects the client at ``address`` (ip:port)" - return self.execute_command('CLIENT KILL', address) - - def client_kill_filter(self, _id=None, _type=None, addr=None, - skipme=None, laddr=None): - """ - Disconnects client(s) using a variety of filter options - :param id: Kills a client by its unique ID field - :param type: Kills a client by type where type is one of 'normal', - 'master', 'slave' or 'pubsub' - :param addr: Kills a client by its 'address:port' - :param skipme: If True, then the client calling the command - :param laddr: Kills a cient by its 'local (bind) address:port' - will not get killed even if it is identified by one of the filter - options. If skipme is not provided, the server defaults to skipme=True - """ - args = [] - if _type is not None: - client_types = ('normal', 'master', 'slave', 'pubsub') - if str(_type).lower() not in client_types: - raise DataError("CLIENT KILL type must be one of %r" % ( - client_types,)) - args.extend((b'TYPE', _type)) - if skipme is not None: - if not isinstance(skipme, bool): - raise DataError("CLIENT KILL skipme must be a bool") - if skipme: - args.extend((b'SKIPME', b'YES')) - else: - args.extend((b'SKIPME', b'NO')) - if _id is not None: - args.extend((b'ID', _id)) - if addr is not None: - args.extend((b'ADDR', addr)) - if laddr is not None: - args.extend((b'LADDR', laddr)) - if not args: - raise DataError("CLIENT KILL ... ... " - " must specify at least one filter") - return self.execute_command('CLIENT KILL', *args) - - def client_info(self): - """ - Returns information and statistics about the current - client connection. - """ - return self.execute_command('CLIENT INFO') - - def client_list(self, _type=None, client_id=None): - """ - Returns a list of currently connected clients. - If type of client specified, only that type will be returned. - :param _type: optional. one of the client types (normal, master, - replica, pubsub) - """ - "Returns a list of currently connected clients" - args = [] - if _type is not None: - client_types = ('normal', 'master', 'replica', 'pubsub') - if str(_type).lower() not in client_types: - raise DataError("CLIENT LIST _type must be one of %r" % ( - client_types,)) - args.append(b'TYPE') - args.append(_type) - if client_id is not None: - args.append(b"ID") - args.append(client_id) - return self.execute_command('CLIENT LIST', *args) - - def client_getname(self): - "Returns the current connection name" - return self.execute_command('CLIENT GETNAME') - - def client_id(self): - "Returns the current connection id" - return self.execute_command('CLIENT ID') - - def client_setname(self, name): - "Sets the current connection name" - return self.execute_command('CLIENT SETNAME', name) - - def client_unblock(self, client_id, error=False): - """ - Unblocks a connection by its client id. - If ``error`` is True, unblocks the client with a special error message. - If ``error`` is False (default), the client is unblocked using the - regular timeout mechanism. - """ - args = ['CLIENT UNBLOCK', int(client_id)] - if error: - args.append(b'ERROR') - return self.execute_command(*args) - - def client_pause(self, timeout): - """ - Suspend all the Redis clients for the specified amount of time - :param timeout: milliseconds to pause clients - """ - if not isinstance(timeout, int): - raise DataError("CLIENT PAUSE timeout must be an integer") - return self.execute_command('CLIENT PAUSE', str(timeout)) - - def client_unpause(self): - """ - Unpause all redis clients - """ - return self.execute_command('CLIENT UNPAUSE') - - def readwrite(self): - "Disables read queries for a connection to a Redis Cluster slave node" - return self.execute_command('READWRITE') - - def readonly(self): - "Enables read queries for a connection to a Redis Cluster replica node" - return self.execute_command('READONLY') - - def config_get(self, pattern="*"): - "Return a dictionary of configuration based on the ``pattern``" - return self.execute_command('CONFIG GET', pattern) - - def config_set(self, name, value): - "Set config item ``name`` with ``value``" - return self.execute_command('CONFIG SET', name, value) - - def config_resetstat(self): - "Reset runtime statistics" - return self.execute_command('CONFIG RESETSTAT') - - def config_rewrite(self): - "Rewrite config file with the minimal change to reflect running config" - return self.execute_command('CONFIG REWRITE') - - def dbsize(self): - "Returns the number of keys in the current database" - return self.execute_command('DBSIZE') - - def debug_object(self, key): - "Returns version specific meta information about a given key" - return self.execute_command('DEBUG OBJECT', key) - - def echo(self, value): - "Echo the string back from the server" - return self.execute_command('ECHO', value) - - def flushall(self, asynchronous=False): - """ - Delete all keys in all databases on the current host. - - ``asynchronous`` indicates whether the operation is - executed asynchronously by the server. - """ - args = [] - if asynchronous: - args.append(b'ASYNC') - return self.execute_command('FLUSHALL', *args) - - def flushdb(self, asynchronous=False): - """ - Delete all keys in the current database. - - ``asynchronous`` indicates whether the operation is - executed asynchronously by the server. - """ - args = [] - if asynchronous: - args.append(b'ASYNC') - return self.execute_command('FLUSHDB', *args) - - def swapdb(self, first, second): - "Swap two databases" - return self.execute_command('SWAPDB', first, second) - - def info(self, section=None): - """ - Returns a dictionary containing information about the Redis server - - The ``section`` option can be used to select a specific section - of information - - The section option is not supported by older versions of Redis Server, - and will generate ResponseError - """ - if section is None: - return self.execute_command('INFO') - else: - return self.execute_command('INFO', section) - - def lastsave(self): - """ - Return a Python datetime object representing the last time the - Redis database was saved to disk - """ - return self.execute_command('LASTSAVE') - - def migrate(self, host, port, keys, destination_db, timeout, - copy=False, replace=False, auth=None): - """ - Migrate 1 or more keys from the current Redis server to a different - server specified by the ``host``, ``port`` and ``destination_db``. - - The ``timeout``, specified in milliseconds, indicates the maximum - time the connection between the two servers can be idle before the - command is interrupted. - - If ``copy`` is True, the specified ``keys`` are NOT deleted from - the source server. - - If ``replace`` is True, this operation will overwrite the keys - on the destination server if they exist. - - If ``auth`` is specified, authenticate to the destination server with - the password provided. - """ - keys = list_or_args(keys, []) - if not keys: - raise DataError('MIGRATE requires at least one key') - pieces = [] - if copy: - pieces.append(b'COPY') - if replace: - pieces.append(b'REPLACE') - if auth: - pieces.append(b'AUTH') - pieces.append(auth) - pieces.append(b'KEYS') - pieces.extend(keys) - return self.execute_command('MIGRATE', host, port, '', destination_db, - timeout, *pieces) - - def object(self, infotype, key): - "Return the encoding, idletime, or refcount about the key" - return self.execute_command('OBJECT', infotype, key, infotype=infotype) - - def memory_stats(self): - "Return a dictionary of memory stats" - return self.execute_command('MEMORY STATS') - - def memory_usage(self, key, samples=None): - """ - Return the total memory usage for key, its value and associated - administrative overheads. - - For nested data structures, ``samples`` is the number of elements to - sample. If left unspecified, the server's default is 5. Use 0 to sample - all elements. - """ - args = [] - if isinstance(samples, int): - args.extend([b'SAMPLES', samples]) - return self.execute_command('MEMORY USAGE', key, *args) - - def memory_purge(self): - "Attempts to purge dirty pages for reclamation by allocator" - return self.execute_command('MEMORY PURGE') - - def ping(self): - "Ping the Redis server" - return self.execute_command('PING') - - def save(self): - """ - Tell the Redis server to save its data to disk, - blocking until the save is complete - """ - return self.execute_command('SAVE') - - def sentinel(self, *args): - "Redis Sentinel's SENTINEL command." - warnings.warn( - DeprecationWarning('Use the individual sentinel_* methods')) - - def sentinel_get_master_addr_by_name(self, service_name): - "Returns a (host, port) pair for the given ``service_name``" - return self.execute_command('SENTINEL GET-MASTER-ADDR-BY-NAME', - service_name) - - def sentinel_master(self, service_name): - "Returns a dictionary containing the specified masters state." - return self.execute_command('SENTINEL MASTER', service_name) - - def sentinel_masters(self): - "Returns a list of dictionaries containing each master's state." - return self.execute_command('SENTINEL MASTERS') - - def sentinel_monitor(self, name, ip, port, quorum): - "Add a new master to Sentinel to be monitored" - return self.execute_command('SENTINEL MONITOR', name, ip, port, quorum) - - def sentinel_remove(self, name): - "Remove a master from Sentinel's monitoring" - return self.execute_command('SENTINEL REMOVE', name) - - def sentinel_sentinels(self, service_name): - "Returns a list of sentinels for ``service_name``" - return self.execute_command('SENTINEL SENTINELS', service_name) - - def sentinel_set(self, name, option, value): - "Set Sentinel monitoring parameters for a given master" - return self.execute_command('SENTINEL SET', name, option, value) - - def sentinel_slaves(self, service_name): - "Returns a list of slaves for ``service_name``" - return self.execute_command('SENTINEL SLAVES', service_name) - - def shutdown(self, save=False, nosave=False): - """Shutdown the Redis server. If Redis has persistence configured, - data will be flushed before shutdown. If the "save" option is set, - a data flush will be attempted even if there is no persistence - configured. If the "nosave" option is set, no data flush will be - attempted. The "save" and "nosave" options cannot both be set. - """ - if save and nosave: - raise DataError('SHUTDOWN save and nosave cannot both be set') - args = ['SHUTDOWN'] - if save: - args.append('SAVE') - if nosave: - args.append('NOSAVE') - try: - self.execute_command(*args) - except ConnectionError: - # a ConnectionError here is expected - return - raise RedisError("SHUTDOWN seems to have failed.") - - def slaveof(self, host=None, port=None): - """ - Set the server to be a replicated slave of the instance identified - by the ``host`` and ``port``. If called without arguments, the - instance is promoted to a master instead. - """ - if host is None and port is None: - return self.execute_command('SLAVEOF', b'NO', b'ONE') - return self.execute_command('SLAVEOF', host, port) - - def slowlog_get(self, num=None): - """ - Get the entries from the slowlog. If ``num`` is specified, get the - most recent ``num`` items. - """ - args = ['SLOWLOG GET'] - if num is not None: - args.append(num) - decode_responses = self.connection_pool.connection_kwargs.get( - 'decode_responses', False) - return self.execute_command(*args, decode_responses=decode_responses) - - def slowlog_len(self): - "Get the number of items in the slowlog" - return self.execute_command('SLOWLOG LEN') - - def slowlog_reset(self): - "Remove all items in the slowlog" - return self.execute_command('SLOWLOG RESET') - - def time(self): - """ - Returns the server time as a 2-item tuple of ints: - (seconds since epoch, microseconds into this second). - """ - return self.execute_command('TIME') - - def wait(self, num_replicas, timeout): - """ - Redis synchronous replication - That returns the number of replicas that processed the query when - we finally have at least ``num_replicas``, or when the ``timeout`` was - reached. - """ - return self.execute_command('WAIT', num_replicas, timeout) - - # BASIC KEY COMMANDS - def append(self, key, value): - """ - Appends the string ``value`` to the value at ``key``. If ``key`` - doesn't already exist, create it with a value of ``value``. - Returns the new length of the value at ``key``. - """ - return self.execute_command('APPEND', key, value) - - def bitcount(self, key, start=None, end=None): - """ - Returns the count of set bits in the value of ``key``. Optional - ``start`` and ``end`` parameters indicate which bytes to consider - """ - params = [key] - if start is not None and end is not None: - params.append(start) - params.append(end) - elif (start is not None and end is None) or \ - (end is not None and start is None): - raise DataError("Both start and end must be specified") - return self.execute_command('BITCOUNT', *params) - - def bitfield(self, key, default_overflow=None): - """ - Return a BitFieldOperation instance to conveniently construct one or - more bitfield operations on ``key``. - """ - return BitFieldOperation(self, key, default_overflow=default_overflow) - - def bitop(self, operation, dest, *keys): - """ - Perform a bitwise operation using ``operation`` between ``keys`` and - store the result in ``dest``. - """ - return self.execute_command('BITOP', operation, dest, *keys) - - def bitpos(self, key, bit, start=None, end=None): - """ - Return the position of the first bit set to 1 or 0 in a string. - ``start`` and ``end`` defines search range. The range is interpreted - as a range of bytes and not a range of bits, so start=0 and end=2 - means to look at the first three bytes. - """ - if bit not in (0, 1): - raise DataError('bit must be 0 or 1') - params = [key, bit] - - start is not None and params.append(start) - - if start is not None and end is not None: - params.append(end) - elif start is None and end is not None: - raise DataError("start argument is not set, " - "when end is specified") - return self.execute_command('BITPOS', *params) - - def copy(self, source, destination, destination_db=None, replace=False): - """ - Copy the value stored in the ``source`` key to the ``destination`` key. - - ``destination_db`` an alternative destination database. By default, - the ``destination`` key is created in the source Redis database. - - ``replace`` whether the ``destination`` key should be removed before - copying the value to it. By default, the value is not copied if - the ``destination`` key already exists. - """ - params = [source, destination] - if destination_db is not None: - params.extend(["DB", destination_db]) - if replace: - params.append("REPLACE") - return self.execute_command('COPY', *params) - - def decr(self, name, amount=1): - """ - Decrements the value of ``key`` by ``amount``. If no key exists, - the value will be initialized as 0 - ``amount`` - """ - # An alias for ``decr()``, because it is already implemented - # as DECRBY redis command. - return self.decrby(name, amount) - - def decrby(self, name, amount=1): - """ - Decrements the value of ``key`` by ``amount``. If no key exists, - the value will be initialized as 0 - ``amount`` - """ - return self.execute_command('DECRBY', name, amount) - - def delete(self, *names): - "Delete one or more keys specified by ``names``" - return self.execute_command('DEL', *names) - - def __delitem__(self, name): - self.delete(name) - - def dump(self, name): - """ - Return a serialized version of the value stored at the specified key. - If key does not exist a nil bulk reply is returned. - """ - return self.execute_command('DUMP', name) - - def exists(self, *names): - "Returns the number of ``names`` that exist" - return self.execute_command('EXISTS', *names) - __contains__ = exists - - def expire(self, name, time): - """ - Set an expire flag on key ``name`` for ``time`` seconds. ``time`` - can be represented by an integer or a Python timedelta object. - """ - if isinstance(time, datetime.timedelta): - time = int(time.total_seconds()) - return self.execute_command('EXPIRE', name, time) - - def expireat(self, name, when): - """ - Set an expire flag on key ``name``. ``when`` can be represented - as an integer indicating unix time or a Python datetime object. - """ - if isinstance(when, datetime.datetime): - when = int(mod_time.mktime(when.timetuple())) - return self.execute_command('EXPIREAT', name, when) - - def get(self, name): - """ - Return the value at key ``name``, or None if the key doesn't exist - """ - return self.execute_command('GET', name) - - def getdel(self, name): - """ - Get the value at key ``name`` and delete the key. This command - is similar to GET, except for the fact that it also deletes - the key on success (if and only if the key's value type - is a string). - """ - return self.execute_command('GETDEL', name) - - def getex(self, name, - ex=None, px=None, exat=None, pxat=None, persist=False): - """ - Get the value of key and optionally set its expiration. - GETEX is similar to GET, but is a write command with - additional options. All time parameters can be given as - datetime.timedelta or integers. - - ``ex`` sets an expire flag on key ``name`` for ``ex`` seconds. - - ``px`` sets an expire flag on key ``name`` for ``px`` milliseconds. - - ``exat`` sets an expire flag on key ``name`` for ``ex`` seconds, - specified in unix time. - - ``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds, - specified in unix time. - - ``persist`` remove the time to live associated with ``name``. - """ - - opset = set([ex, px, exat, pxat]) - if len(opset) > 2 or len(opset) > 1 and persist: - raise DataError("``ex``, ``px``, ``exat``, ``pxat``", - "and ``persist`` are mutually exclusive.") - - pieces = [] - # similar to set command - if ex is not None: - pieces.append('EX') - if isinstance(ex, datetime.timedelta): - ex = int(ex.total_seconds()) - pieces.append(ex) - if px is not None: - pieces.append('PX') - if isinstance(px, datetime.timedelta): - px = int(px.total_seconds() * 1000) - pieces.append(px) - # similar to pexpireat command - if exat is not None: - pieces.append('EXAT') - if isinstance(exat, datetime.datetime): - s = int(exat.microsecond / 1000000) - exat = int(mod_time.mktime(exat.timetuple())) + s - pieces.append(exat) - if pxat is not None: - pieces.append('PXAT') - if isinstance(pxat, datetime.datetime): - ms = int(pxat.microsecond / 1000) - pxat = int(mod_time.mktime(pxat.timetuple())) * 1000 + ms - pieces.append(pxat) - if persist: - pieces.append('PERSIST') - - return self.execute_command('GETEX', name, *pieces) - - def __getitem__(self, name): - """ - Return the value at key ``name``, raises a KeyError if the key - doesn't exist. - """ - value = self.get(name) - if value is not None: - return value - raise KeyError(name) - - def getbit(self, name, offset): - "Returns a boolean indicating the value of ``offset`` in ``name``" - return self.execute_command('GETBIT', name, offset) - - def getrange(self, key, start, end): - """ - Returns the substring of the string value stored at ``key``, - determined by the offsets ``start`` and ``end`` (both are inclusive) - """ - return self.execute_command('GETRANGE', key, start, end) - - def getset(self, name, value): - """ - Sets the value at key ``name`` to ``value`` - and returns the old value at key ``name`` atomically. - - As per Redis 6.2, GETSET is considered deprecated. - Please use SET with GET parameter in new code. - """ - return self.execute_command('GETSET', name, value) - - def incr(self, name, amount=1): - """ - Increments the value of ``key`` by ``amount``. If no key exists, - the value will be initialized as ``amount`` - """ - return self.incrby(name, amount) - - def incrby(self, name, amount=1): - """ - Increments the value of ``key`` by ``amount``. If no key exists, - the value will be initialized as ``amount`` - """ - # An alias for ``incr()``, because it is already implemented - # as INCRBY redis command. - return self.execute_command('INCRBY', name, amount) - - def incrbyfloat(self, name, amount=1.0): - """ - Increments the value at key ``name`` by floating ``amount``. - If no key exists, the value will be initialized as ``amount`` - """ - return self.execute_command('INCRBYFLOAT', name, amount) - - def keys(self, pattern='*'): - "Returns a list of keys matching ``pattern``" - return self.execute_command('KEYS', pattern) - - def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): - """ - Atomically returns and removes the first/last element of a list, - pushing it as the first/last element on the destination list. - Returns the element being popped and pushed. - """ - params = [first_list, second_list, src, dest] - return self.execute_command("LMOVE", *params) - - def blmove(self, first_list, second_list, timeout, - src="LEFT", dest="RIGHT"): - """ - Blocking version of lmove. - """ - params = [first_list, second_list, src, dest, timeout] - return self.execute_command("BLMOVE", *params) - - def mget(self, keys, *args): - """ - Returns a list of values ordered identically to ``keys`` - """ - args = list_or_args(keys, args) - options = {} - if not args: - options[EMPTY_RESPONSE] = [] - return self.execute_command('MGET', *args, **options) - - def mset(self, mapping): - """ - Sets key/values based on a mapping. Mapping is a dictionary of - key/value pairs. Both keys and values should be strings or types that - can be cast to a string via str(). - """ - items = [] - for pair in mapping.items(): - items.extend(pair) - return self.execute_command('MSET', *items) - - def msetnx(self, mapping): - """ - Sets key/values based on a mapping if none of the keys are already set. - Mapping is a dictionary of key/value pairs. Both keys and values - should be strings or types that can be cast to a string via str(). - Returns a boolean indicating if the operation was successful. - """ - items = [] - for pair in mapping.items(): - items.extend(pair) - return self.execute_command('MSETNX', *items) - - def move(self, name, db): - "Moves the key ``name`` to a different Redis database ``db``" - return self.execute_command('MOVE', name, db) - - def persist(self, name): - "Removes an expiration on ``name``" - return self.execute_command('PERSIST', name) - - def pexpire(self, name, time): - """ - Set an expire flag on key ``name`` for ``time`` milliseconds. - ``time`` can be represented by an integer or a Python timedelta - object. - """ - if isinstance(time, datetime.timedelta): - time = int(time.total_seconds() * 1000) - return self.execute_command('PEXPIRE', name, time) - - def pexpireat(self, name, when): - """ - Set an expire flag on key ``name``. ``when`` can be represented - as an integer representing unix time in milliseconds (unix time * 1000) - or a Python datetime object. - """ - if isinstance(when, datetime.datetime): - ms = int(when.microsecond / 1000) - when = int(mod_time.mktime(when.timetuple())) * 1000 + ms - return self.execute_command('PEXPIREAT', name, when) - - def psetex(self, name, time_ms, value): - """ - Set the value of key ``name`` to ``value`` that expires in ``time_ms`` - milliseconds. ``time_ms`` can be represented by an integer or a Python - timedelta object - """ - if isinstance(time_ms, datetime.timedelta): - time_ms = int(time_ms.total_seconds() * 1000) - return self.execute_command('PSETEX', name, time_ms, value) - - def pttl(self, name): - "Returns the number of milliseconds until the key ``name`` will expire" - return self.execute_command('PTTL', name) - - def hrandfield(self, key, count=None, withvalues=False): - """ - Return a random field from the hash value stored at key. - - count: if the argument is positive, return an array of distinct fields. - If called with a negative count, the behavior changes and the command - is allowed to return the same field multiple times. In this case, - the number of returned fields is the absolute value of the - specified count. - withvalues: The optional WITHVALUES modifier changes the reply so it - includes the respective values of the randomly selected hash fields. - """ - params = [] - if count is not None: - params.append(count) - if withvalues: - params.append("WITHVALUES") - - return self.execute_command("HRANDFIELD", key, *params) - - def randomkey(self): - "Returns the name of a random key" - return self.execute_command('RANDOMKEY') - - def rename(self, src, dst): - """ - Rename key ``src`` to ``dst`` - """ - return self.execute_command('RENAME', src, dst) - - def renamenx(self, src, dst): - "Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist" - return self.execute_command('RENAMENX', src, dst) - - def restore(self, name, ttl, value, replace=False, absttl=False): - """ - Create a key using the provided serialized value, previously obtained - using DUMP. - - ``replace`` allows an existing key on ``name`` to be overridden. If - it's not specified an error is raised on collision. - - ``absttl`` if True, specified ``ttl`` should represent an absolute Unix - timestamp in milliseconds in which the key will expire. (Redis 5.0 or - greater). - """ - params = [name, ttl, value] - if replace: - params.append('REPLACE') - if absttl: - params.append('ABSTTL') - return self.execute_command('RESTORE', *params) - - def set(self, name, value, - ex=None, px=None, nx=False, xx=False, keepttl=False, get=False): - """ - Set the value at key ``name`` to ``value`` - - ``ex`` sets an expire flag on key ``name`` for ``ex`` seconds. - - ``px`` sets an expire flag on key ``name`` for ``px`` milliseconds. - - ``nx`` if set to True, set the value at key ``name`` to ``value`` only - if it does not exist. - - ``xx`` if set to True, set the value at key ``name`` to ``value`` only - if it already exists. - - ``keepttl`` if True, retain the time to live associated with the key. - (Available since Redis 6.0) - - ``get`` if True, set the value at key ``name`` to ``value`` and return - the old value stored at key, or None when key did not exist. - (Available since Redis 6.2) - """ - pieces = [name, value] - options = {} - if ex is not None: - pieces.append('EX') - if isinstance(ex, datetime.timedelta): - ex = int(ex.total_seconds()) - pieces.append(ex) - if px is not None: - pieces.append('PX') - if isinstance(px, datetime.timedelta): - px = int(px.total_seconds() * 1000) - pieces.append(px) - - if nx: - pieces.append('NX') - if xx: - pieces.append('XX') - - if keepttl: - pieces.append('KEEPTTL') - - if get: - pieces.append('GET') - options["get"] = True - - return self.execute_command('SET', *pieces, **options) - - def __setitem__(self, name, value): - self.set(name, value) - - def setbit(self, name, offset, value): - """ - Flag the ``offset`` in ``name`` as ``value``. Returns a boolean - indicating the previous value of ``offset``. - """ - value = value and 1 or 0 - return self.execute_command('SETBIT', name, offset, value) - - def setex(self, name, time, value): - """ - Set the value of key ``name`` to ``value`` that expires in ``time`` - seconds. ``time`` can be represented by an integer or a Python - timedelta object. - """ - if isinstance(time, datetime.timedelta): - time = int(time.total_seconds()) - return self.execute_command('SETEX', name, time, value) - - def setnx(self, name, value): - "Set the value of key ``name`` to ``value`` if key doesn't exist" - return self.execute_command('SETNX', name, value) - - def setrange(self, name, offset, value): - """ - Overwrite bytes in the value of ``name`` starting at ``offset`` with - ``value``. If ``offset`` plus the length of ``value`` exceeds the - length of the original value, the new value will be larger than before. - If ``offset`` exceeds the length of the original value, null bytes - will be used to pad between the end of the previous value and the start - of what's being injected. - - Returns the length of the new string. - """ - return self.execute_command('SETRANGE', name, offset, value) - - def strlen(self, name): - "Return the number of bytes stored in the value of ``name``" - return self.execute_command('STRLEN', name) - - def substr(self, name, start, end=-1): - """ - Return a substring of the string at key ``name``. ``start`` and ``end`` - are 0-based integers specifying the portion of the string to return. - """ - return self.execute_command('SUBSTR', name, start, end) - - def touch(self, *args): - """ - Alters the last access time of a key(s) ``*args``. A key is ignored - if it does not exist. - """ - return self.execute_command('TOUCH', *args) - - def ttl(self, name): - "Returns the number of seconds until the key ``name`` will expire" - return self.execute_command('TTL', name) - - def type(self, name): - "Returns the type of key ``name``" - return self.execute_command('TYPE', name) - - def watch(self, *names): - """ - Watches the values at keys ``names``, or None if the key doesn't exist - """ - warnings.warn(DeprecationWarning('Call WATCH from a Pipeline object')) - - def unwatch(self): - """ - Unwatches the value at key ``name``, or None of the key doesn't exist - """ - warnings.warn( - DeprecationWarning('Call UNWATCH from a Pipeline object')) - - def unlink(self, *names): - "Unlink one or more keys specified by ``names``" - return self.execute_command('UNLINK', *names) - - # LIST COMMANDS - def blpop(self, keys, timeout=0): - """ - LPOP a value off of the first non-empty list - named in the ``keys`` list. - - If none of the lists in ``keys`` has a value to LPOP, then block - for ``timeout`` seconds, or until a value gets pushed on to one - of the lists. - - If timeout is 0, then block indefinitely. - """ - if timeout is None: - timeout = 0 - keys = list_or_args(keys, None) - keys.append(timeout) - return self.execute_command('BLPOP', *keys) - - def brpop(self, keys, timeout=0): - """ - RPOP a value off of the first non-empty list - named in the ``keys`` list. - - If none of the lists in ``keys`` has a value to RPOP, then block - for ``timeout`` seconds, or until a value gets pushed on to one - of the lists. - - If timeout is 0, then block indefinitely. - """ - if timeout is None: - timeout = 0 - keys = list_or_args(keys, None) - keys.append(timeout) - return self.execute_command('BRPOP', *keys) - - def brpoplpush(self, src, dst, timeout=0): - """ - Pop a value off the tail of ``src``, push it on the head of ``dst`` - and then return it. - - This command blocks until a value is in ``src`` or until ``timeout`` - seconds elapse, whichever is first. A ``timeout`` value of 0 blocks - forever. - """ - if timeout is None: - timeout = 0 - return self.execute_command('BRPOPLPUSH', src, dst, timeout) - - def lindex(self, name, index): - """ - Return the item from list ``name`` at position ``index`` - - Negative indexes are supported and will return an item at the - end of the list - """ - return self.execute_command('LINDEX', name, index) - - def linsert(self, name, where, refvalue, value): - """ - Insert ``value`` in list ``name`` either immediately before or after - [``where``] ``refvalue`` - - Returns the new length of the list on success or -1 if ``refvalue`` - is not in the list. - """ - return self.execute_command('LINSERT', name, where, refvalue, value) - - def llen(self, name): - "Return the length of the list ``name``" - return self.execute_command('LLEN', name) - - def lpop(self, name, count=None): - """ - Removes and returns the first elements of the list ``name``. - - By default, the command pops a single element from the beginning of - the list. When provided with the optional ``count`` argument, the reply - will consist of up to count elements, depending on the list's length. - """ - if count is not None: - return self.execute_command('LPOP', name, count) - else: - return self.execute_command('LPOP', name) - - def lpush(self, name, *values): - "Push ``values`` onto the head of the list ``name``" - return self.execute_command('LPUSH', name, *values) - - def lpushx(self, name, value): - "Push ``value`` onto the head of the list ``name`` if ``name`` exists" - return self.execute_command('LPUSHX', name, value) - - def lrange(self, name, start, end): - """ - Return a slice of the list ``name`` between - position ``start`` and ``end`` - - ``start`` and ``end`` can be negative numbers just like - Python slicing notation - """ - return self.execute_command('LRANGE', name, start, end) - - def lrem(self, name, count, value): - """ - Remove the first ``count`` occurrences of elements equal to ``value`` - from the list stored at ``name``. - - The count argument influences the operation in the following ways: - count > 0: Remove elements equal to value moving from head to tail. - count < 0: Remove elements equal to value moving from tail to head. - count = 0: Remove all elements equal to value. - """ - return self.execute_command('LREM', name, count, value) - - def lset(self, name, index, value): - "Set ``position`` of list ``name`` to ``value``" - return self.execute_command('LSET', name, index, value) - - def ltrim(self, name, start, end): - """ - Trim the list ``name``, removing all values not within the slice - between ``start`` and ``end`` - - ``start`` and ``end`` can be negative numbers just like - Python slicing notation - """ - return self.execute_command('LTRIM', name, start, end) - - def rpop(self, name, count=None): - """ - Removes and returns the last elements of the list ``name``. - - By default, the command pops a single element from the end of the list. - When provided with the optional ``count`` argument, the reply will - consist of up to count elements, depending on the list's length. - """ - if count is not None: - return self.execute_command('RPOP', name, count) - else: - return self.execute_command('RPOP', name) - - def rpoplpush(self, src, dst): - """ - RPOP a value off of the ``src`` list and atomically LPUSH it - on to the ``dst`` list. Returns the value. - """ - return self.execute_command('RPOPLPUSH', src, dst) - - def rpush(self, name, *values): - "Push ``values`` onto the tail of the list ``name``" - return self.execute_command('RPUSH', name, *values) - - def rpushx(self, name, value): - "Push ``value`` onto the tail of the list ``name`` if ``name`` exists" - return self.execute_command('RPUSHX', name, value) - - def lpos(self, name, value, rank=None, count=None, maxlen=None): - """ - Get position of ``value`` within the list ``name`` - - If specified, ``rank`` indicates the "rank" of the first element to - return in case there are multiple copies of ``value`` in the list. - By default, LPOS returns the position of the first occurrence of - ``value`` in the list. When ``rank`` 2, LPOS returns the position of - the second ``value`` in the list. If ``rank`` is negative, LPOS - searches the list in reverse. For example, -1 would return the - position of the last occurrence of ``value`` and -2 would return the - position of the next to last occurrence of ``value``. - - If specified, ``count`` indicates that LPOS should return a list of - up to ``count`` positions. A ``count`` of 2 would return a list of - up to 2 positions. A ``count`` of 0 returns a list of all positions - matching ``value``. When ``count`` is specified and but ``value`` - does not exist in the list, an empty list is returned. - - If specified, ``maxlen`` indicates the maximum number of list - elements to scan. A ``maxlen`` of 1000 will only return the - position(s) of items within the first 1000 entries in the list. - A ``maxlen`` of 0 (the default) will scan the entire list. - """ - pieces = [name, value] - if rank is not None: - pieces.extend(['RANK', rank]) - - if count is not None: - pieces.extend(['COUNT', count]) - - if maxlen is not None: - pieces.extend(['MAXLEN', maxlen]) - - return self.execute_command('LPOS', *pieces) - - def sort(self, name, start=None, num=None, by=None, get=None, - desc=False, alpha=False, store=None, groups=False): - """ - Sort and return the list, set or sorted set at ``name``. - - ``start`` and ``num`` allow for paging through the sorted data - - ``by`` allows using an external key to weight and sort the items. - Use an "*" to indicate where in the key the item value is located - - ``get`` allows for returning items from external keys rather than the - sorted data itself. Use an "*" to indicate where in the key - the item value is located - - ``desc`` allows for reversing the sort - - ``alpha`` allows for sorting lexicographically rather than numerically - - ``store`` allows for storing the result of the sort into - the key ``store`` - - ``groups`` if set to True and if ``get`` contains at least two - elements, sort will return a list of tuples, each containing the - values fetched from the arguments to ``get``. - - """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - - pieces = [name] - if by is not None: - pieces.append(b'BY') - pieces.append(by) - if start is not None and num is not None: - pieces.append(b'LIMIT') - pieces.append(start) - pieces.append(num) - if get is not None: - # If get is a string assume we want to get a single value. - # Otherwise assume it's an interable and we want to get multiple - # values. We can't just iterate blindly because strings are - # iterable. - if isinstance(get, (bytes, str)): - pieces.append(b'GET') - pieces.append(get) - else: - for g in get: - pieces.append(b'GET') - pieces.append(g) - if desc: - pieces.append(b'DESC') - if alpha: - pieces.append(b'ALPHA') - if store is not None: - pieces.append(b'STORE') - pieces.append(store) - - if groups: - if not get or isinstance(get, (bytes, str)) or len(get) < 2: - raise DataError('when using "groups" the "get" argument ' - 'must be specified and contain at least ' - 'two keys') - - options = {'groups': len(get) if groups else None} - return self.execute_command('SORT', *pieces, **options) - - # SCAN COMMANDS - def scan(self, cursor=0, match=None, count=None, _type=None): - """ - Incrementally return lists of key names. Also return a cursor - indicating the scan position. - - ``match`` allows for filtering the keys by pattern - - ``count`` provides a hint to Redis about the number of keys to - return per batch. - - ``_type`` filters the returned values by a particular Redis type. - Stock Redis instances allow for the following types: - HASH, LIST, SET, STREAM, STRING, ZSET - Additionally, Redis modules can expose other types as well. - """ - pieces = [cursor] - if match is not None: - pieces.extend([b'MATCH', match]) - if count is not None: - pieces.extend([b'COUNT', count]) - if _type is not None: - pieces.extend([b'TYPE', _type]) - return self.execute_command('SCAN', *pieces) - - def scan_iter(self, match=None, count=None, _type=None): - """ - Make an iterator using the SCAN command so that the client doesn't - need to remember the cursor position. - - ``match`` allows for filtering the keys by pattern - - ``count`` provides a hint to Redis about the number of keys to - return per batch. - - ``_type`` filters the returned values by a particular Redis type. - Stock Redis instances allow for the following types: - HASH, LIST, SET, STREAM, STRING, ZSET - Additionally, Redis modules can expose other types as well. - """ - cursor = '0' - while cursor != 0: - cursor, data = self.scan(cursor=cursor, match=match, - count=count, _type=_type) - yield from data - - def sscan(self, name, cursor=0, match=None, count=None): - """ - Incrementally return lists of elements in a set. Also return a cursor - indicating the scan position. - - ``match`` allows for filtering the keys by pattern - - ``count`` allows for hint the minimum number of returns - """ - pieces = [name, cursor] - if match is not None: - pieces.extend([b'MATCH', match]) - if count is not None: - pieces.extend([b'COUNT', count]) - return self.execute_command('SSCAN', *pieces) - - def sscan_iter(self, name, match=None, count=None): - """ - Make an iterator using the SSCAN command so that the client doesn't - need to remember the cursor position. - - ``match`` allows for filtering the keys by pattern - - ``count`` allows for hint the minimum number of returns - """ - cursor = '0' - while cursor != 0: - cursor, data = self.sscan(name, cursor=cursor, - match=match, count=count) - yield from data - - def hscan(self, name, cursor=0, match=None, count=None): - """ - Incrementally return key/value slices in a hash. Also return a cursor - indicating the scan position. - - ``match`` allows for filtering the keys by pattern - - ``count`` allows for hint the minimum number of returns - """ - pieces = [name, cursor] - if match is not None: - pieces.extend([b'MATCH', match]) - if count is not None: - pieces.extend([b'COUNT', count]) - return self.execute_command('HSCAN', *pieces) - - def hscan_iter(self, name, match=None, count=None): - """ - Make an iterator using the HSCAN command so that the client doesn't - need to remember the cursor position. - - ``match`` allows for filtering the keys by pattern - - ``count`` allows for hint the minimum number of returns - """ - cursor = '0' - while cursor != 0: - cursor, data = self.hscan(name, cursor=cursor, - match=match, count=count) - yield from data.items() - - def zscan(self, name, cursor=0, match=None, count=None, - score_cast_func=float): - """ - Incrementally return lists of elements in a sorted set. Also return a - cursor indicating the scan position. - - ``match`` allows for filtering the keys by pattern - - ``count`` allows for hint the minimum number of returns - - ``score_cast_func`` a callable used to cast the score return value - """ - pieces = [name, cursor] - if match is not None: - pieces.extend([b'MATCH', match]) - if count is not None: - pieces.extend([b'COUNT', count]) - options = {'score_cast_func': score_cast_func} - return self.execute_command('ZSCAN', *pieces, **options) - - def zscan_iter(self, name, match=None, count=None, - score_cast_func=float): - """ - Make an iterator using the ZSCAN command so that the client doesn't - need to remember the cursor position. - - ``match`` allows for filtering the keys by pattern - - ``count`` allows for hint the minimum number of returns - - ``score_cast_func`` a callable used to cast the score return value - """ - cursor = '0' - while cursor != 0: - cursor, data = self.zscan(name, cursor=cursor, match=match, - count=count, - score_cast_func=score_cast_func) - yield from data - - # SET COMMANDS - def sadd(self, name, *values): - "Add ``value(s)`` to set ``name``" - return self.execute_command('SADD', name, *values) - - def scard(self, name): - "Return the number of elements in set ``name``" - return self.execute_command('SCARD', name) - - def sdiff(self, keys, *args): - "Return the difference of sets specified by ``keys``" - args = list_or_args(keys, args) - return self.execute_command('SDIFF', *args) - - def sdiffstore(self, dest, keys, *args): - """ - Store the difference of sets specified by ``keys`` into a new - set named ``dest``. Returns the number of keys in the new set. - """ - args = list_or_args(keys, args) - return self.execute_command('SDIFFSTORE', dest, *args) - - def sinter(self, keys, *args): - "Return the intersection of sets specified by ``keys``" - args = list_or_args(keys, args) - return self.execute_command('SINTER', *args) - - def sinterstore(self, dest, keys, *args): - """ - Store the intersection of sets specified by ``keys`` into a new - set named ``dest``. Returns the number of keys in the new set. - """ - args = list_or_args(keys, args) - return self.execute_command('SINTERSTORE', dest, *args) - - def sismember(self, name, value): - "Return a boolean indicating if ``value`` is a member of set ``name``" - return self.execute_command('SISMEMBER', name, value) - - def smembers(self, name): - "Return all members of the set ``name``" - return self.execute_command('SMEMBERS', name) - - def smove(self, src, dst, value): - "Move ``value`` from set ``src`` to set ``dst`` atomically" - return self.execute_command('SMOVE', src, dst, value) - - def spop(self, name, count=None): - "Remove and return a random member of set ``name``" - args = (count is not None) and [count] or [] - return self.execute_command('SPOP', name, *args) - - def srandmember(self, name, number=None): - """ - If ``number`` is None, returns a random member of set ``name``. - - If ``number`` is supplied, returns a list of ``number`` random - members of set ``name``. Note this is only available when running - Redis 2.6+. - """ - args = (number is not None) and [number] or [] - return self.execute_command('SRANDMEMBER', name, *args) - - def srem(self, name, *values): - "Remove ``values`` from set ``name``" - return self.execute_command('SREM', name, *values) - - def sunion(self, keys, *args): - "Return the union of sets specified by ``keys``" - args = list_or_args(keys, args) - return self.execute_command('SUNION', *args) - - def sunionstore(self, dest, keys, *args): - """ - Store the union of sets specified by ``keys`` into a new - set named ``dest``. Returns the number of keys in the new set. - """ - args = list_or_args(keys, args) - return self.execute_command('SUNIONSTORE', dest, *args) - - # STREAMS COMMANDS - def xack(self, name, groupname, *ids): - """ - Acknowledges the successful processing of one or more messages. - name: name of the stream. - groupname: name of the consumer group. - *ids: message ids to acknowledge. - """ - return self.execute_command('XACK', name, groupname, *ids) - - def xadd(self, name, fields, id='*', maxlen=None, approximate=True, - nomkstream=False): - """ - Add to a stream. - name: name of the stream - fields: dict of field/value pairs to insert into the stream - id: Location to insert this record. By default it is appended. - maxlen: truncate old stream members beyond this size - approximate: actual stream length may be slightly more than maxlen - nomkstream: When set to true, do not make a stream - """ - pieces = [] - if maxlen is not None: - if not isinstance(maxlen, int) or maxlen < 1: - raise DataError('XADD maxlen must be a positive integer') - pieces.append(b'MAXLEN') - if approximate: - pieces.append(b'~') - pieces.append(str(maxlen)) - if nomkstream: - pieces.append(b'NOMKSTREAM') - pieces.append(id) - if not isinstance(fields, dict) or len(fields) == 0: - raise DataError('XADD fields must be a non-empty dict') - for pair in fields.items(): - pieces.extend(pair) - return self.execute_command('XADD', name, *pieces) - - def xautoclaim(self, name, groupname, consumername, min_idle_time, - start_id=0, count=None, justid=False): - """ - Transfers ownership of pending stream entries that match the specified - criteria. Conceptually, equivalent to calling XPENDING and then XCLAIM, - but provides a more straightforward way to deal with message delivery - failures via SCAN-like semantics. - name: name of the stream. - groupname: name of the consumer group. - consumername: name of a consumer that claims the message. - min_idle_time: filter messages that were idle less than this amount of - milliseconds. - start_id: filter messages with equal or greater ID. - count: optional integer, upper limit of the number of entries that the - command attempts to claim. Set to 100 by default. - justid: optional boolean, false by default. Return just an array of IDs - of messages successfully claimed, without returning the actual message - """ - try: - if int(min_idle_time) < 0: - raise DataError("XAUTOCLAIM min_idle_time must be a non" - "negative integer") - except TypeError: - pass - - kwargs = {} - pieces = [name, groupname, consumername, min_idle_time, start_id] - - try: - if int(count) < 0: - raise DataError("XPENDING count must be a integer >= 0") - pieces.extend([b'COUNT', count]) - except TypeError: - pass - if justid: - pieces.append(b'JUSTID') - kwargs['parse_justid'] = True - - return self.execute_command('XAUTOCLAIM', *pieces, **kwargs) - - def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, - idle=None, time=None, retrycount=None, force=False, - justid=False): - """ - Changes the ownership of a pending message. - name: name of the stream. - groupname: name of the consumer group. - consumername: name of a consumer that claims the message. - min_idle_time: filter messages that were idle less than this amount of - milliseconds - message_ids: non-empty list or tuple of message IDs to claim - idle: optional. Set the idle time (last time it was delivered) of the - message in ms - time: optional integer. This is the same as idle but instead of a - relative amount of milliseconds, it sets the idle time to a specific - Unix time (in milliseconds). - retrycount: optional integer. set the retry counter to the specified - value. This counter is incremented every time a message is delivered - again. - force: optional boolean, false by default. Creates the pending message - entry in the PEL even if certain specified IDs are not already in the - PEL assigned to a different client. - justid: optional boolean, false by default. Return just an array of IDs - of messages successfully claimed, without returning the actual message - """ - if not isinstance(min_idle_time, int) or min_idle_time < 0: - raise DataError("XCLAIM min_idle_time must be a non negative " - "integer") - if not isinstance(message_ids, (list, tuple)) or not message_ids: - raise DataError("XCLAIM message_ids must be a non empty list or " - "tuple of message IDs to claim") - - kwargs = {} - pieces = [name, groupname, consumername, str(min_idle_time)] - pieces.extend(list(message_ids)) - - if idle is not None: - if not isinstance(idle, int): - raise DataError("XCLAIM idle must be an integer") - pieces.extend((b'IDLE', str(idle))) - if time is not None: - if not isinstance(time, int): - raise DataError("XCLAIM time must be an integer") - pieces.extend((b'TIME', str(time))) - if retrycount is not None: - if not isinstance(retrycount, int): - raise DataError("XCLAIM retrycount must be an integer") - pieces.extend((b'RETRYCOUNT', str(retrycount))) - - if force: - if not isinstance(force, bool): - raise DataError("XCLAIM force must be a boolean") - pieces.append(b'FORCE') - if justid: - if not isinstance(justid, bool): - raise DataError("XCLAIM justid must be a boolean") - pieces.append(b'JUSTID') - kwargs['parse_justid'] = True - return self.execute_command('XCLAIM', *pieces, **kwargs) - - def xdel(self, name, *ids): - """ - Deletes one or more messages from a stream. - name: name of the stream. - *ids: message ids to delete. - """ - return self.execute_command('XDEL', name, *ids) - - def xgroup_create(self, name, groupname, id='$', mkstream=False): - """ - Create a new consumer group associated with a stream. - name: name of the stream. - groupname: name of the consumer group. - id: ID of the last item in the stream to consider already delivered. - """ - pieces = ['XGROUP CREATE', name, groupname, id] - if mkstream: - pieces.append(b'MKSTREAM') - return self.execute_command(*pieces) - - def xgroup_delconsumer(self, name, groupname, consumername): - """ - Remove a specific consumer from a consumer group. - Returns the number of pending messages that the consumer had before it - was deleted. - name: name of the stream. - groupname: name of the consumer group. - consumername: name of consumer to delete - """ - return self.execute_command('XGROUP DELCONSUMER', name, groupname, - consumername) - - def xgroup_destroy(self, name, groupname): - """ - Destroy a consumer group. - name: name of the stream. - groupname: name of the consumer group. - """ - return self.execute_command('XGROUP DESTROY', name, groupname) - - def xgroup_setid(self, name, groupname, id): - """ - Set the consumer group last delivered ID to something else. - name: name of the stream. - groupname: name of the consumer group. - id: ID of the last item in the stream to consider already delivered. - """ - return self.execute_command('XGROUP SETID', name, groupname, id) - - def xinfo_consumers(self, name, groupname): - """ - Returns general information about the consumers in the group. - name: name of the stream. - groupname: name of the consumer group. - """ - return self.execute_command('XINFO CONSUMERS', name, groupname) - - def xinfo_groups(self, name): - """ - Returns general information about the consumer groups of the stream. - name: name of the stream. - """ - return self.execute_command('XINFO GROUPS', name) - - def xinfo_stream(self, name): - """ - Returns general information about the stream. - name: name of the stream. - """ - return self.execute_command('XINFO STREAM', name) - - def xlen(self, name): - """ - Returns the number of elements in a given stream. - """ - return self.execute_command('XLEN', name) - - def xpending(self, name, groupname): - """ - Returns information about pending messages of a group. - name: name of the stream. - groupname: name of the consumer group. - """ - return self.execute_command('XPENDING', name, groupname) - - def xpending_range(self, name, groupname, min, max, count, - consumername=None, idle=None): - """ - Returns information about pending messages, in a range. - name: name of the stream. - groupname: name of the consumer group. - min: minimum stream ID. - max: maximum stream ID. - count: number of messages to return - consumername: name of a consumer to filter by (optional). - idle: available from version 6.2. filter entries by their - idle-time, given in milliseconds (optional). - """ - if {min, max, count} == {None}: - if idle is not None or consumername is not None: - raise DataError("if XPENDING is provided with idle time" - " or consumername, it must be provided" - " with min, max and count parameters") - return self.xpending(name, groupname) - - pieces = [name, groupname] - if min is None or max is None or count is None: - raise DataError("XPENDING must be provided with min, max " - "and count parameters, or none of them.") - # idle - try: - if int(idle) < 0: - raise DataError("XPENDING idle must be a integer >= 0") - pieces.extend(['IDLE', idle]) - except TypeError: - pass - # count - try: - if int(count) < 0: - raise DataError("XPENDING count must be a integer >= 0") - pieces.extend([min, max, count]) - except TypeError: - pass - - return self.execute_command('XPENDING', *pieces, parse_detail=True) - - def xrange(self, name, min='-', max='+', count=None): - """ - Read stream values within an interval. - name: name of the stream. - start: first stream ID. defaults to '-', - meaning the earliest available. - finish: last stream ID. defaults to '+', - meaning the latest available. - count: if set, only return this many items, beginning with the - earliest available. - """ - pieces = [min, max] - if count is not None: - if not isinstance(count, int) or count < 1: - raise DataError('XRANGE count must be a positive integer') - pieces.append(b'COUNT') - pieces.append(str(count)) - - return self.execute_command('XRANGE', name, *pieces) - - def xread(self, streams, count=None, block=None): - """ - Block and monitor multiple streams for new data. - streams: a dict of stream names to stream IDs, where - IDs indicate the last ID already seen. - count: if set, only return this many items, beginning with the - earliest available. - block: number of milliseconds to wait, if nothing already present. - """ - pieces = [] - if block is not None: - if not isinstance(block, int) or block < 0: - raise DataError('XREAD block must be a non-negative integer') - pieces.append(b'BLOCK') - pieces.append(str(block)) - if count is not None: - if not isinstance(count, int) or count < 1: - raise DataError('XREAD count must be a positive integer') - pieces.append(b'COUNT') - pieces.append(str(count)) - if not isinstance(streams, dict) or len(streams) == 0: - raise DataError('XREAD streams must be a non empty dict') - pieces.append(b'STREAMS') - keys, values = zip(*streams.items()) - pieces.extend(keys) - pieces.extend(values) - return self.execute_command('XREAD', *pieces) - - def xreadgroup(self, groupname, consumername, streams, count=None, - block=None, noack=False): - """ - Read from a stream via a consumer group. - groupname: name of the consumer group. - consumername: name of the requesting consumer. - streams: a dict of stream names to stream IDs, where - IDs indicate the last ID already seen. - count: if set, only return this many items, beginning with the - earliest available. - block: number of milliseconds to wait, if nothing already present. - noack: do not add messages to the PEL - """ - pieces = [b'GROUP', groupname, consumername] - if count is not None: - if not isinstance(count, int) or count < 1: - raise DataError("XREADGROUP count must be a positive integer") - pieces.append(b'COUNT') - pieces.append(str(count)) - if block is not None: - if not isinstance(block, int) or block < 0: - raise DataError("XREADGROUP block must be a non-negative " - "integer") - pieces.append(b'BLOCK') - pieces.append(str(block)) - if noack: - pieces.append(b'NOACK') - if not isinstance(streams, dict) or len(streams) == 0: - raise DataError('XREADGROUP streams must be a non empty dict') - pieces.append(b'STREAMS') - pieces.extend(streams.keys()) - pieces.extend(streams.values()) - return self.execute_command('XREADGROUP', *pieces) - - def xrevrange(self, name, max='+', min='-', count=None): - """ - Read stream values within an interval, in reverse order. - name: name of the stream - start: first stream ID. defaults to '+', - meaning the latest available. - finish: last stream ID. defaults to '-', - meaning the earliest available. - count: if set, only return this many items, beginning with the - latest available. - """ - pieces = [max, min] - if count is not None: - if not isinstance(count, int) or count < 1: - raise DataError('XREVRANGE count must be a positive integer') - pieces.append(b'COUNT') - pieces.append(str(count)) - - return self.execute_command('XREVRANGE', name, *pieces) - - def xtrim(self, name, maxlen=None, approximate=True, minid=None, - limit=None): - """ - Trims old messages from a stream. - name: name of the stream. - maxlen: truncate old stream messages beyond this size - approximate: actual stream length may be slightly more than maxlen - minin: the minimum id in the stream to query - limit: specifies the maximum number of entries to retrieve - """ - pieces = [] - if maxlen is not None and minid is not None: - raise DataError("Only one of ```maxlen``` or ```minid```", - "may be specified") - - if maxlen is not None: - pieces.append(b'MAXLEN') - if minid is not None: - pieces.append(b'MINID') - if approximate: - pieces.append(b'~') - if maxlen is not None: - pieces.append(maxlen) - if minid is not None: - pieces.append(minid) - if limit is not None: - pieces.append(b"LIMIT") - pieces.append(limit) - - return self.execute_command('XTRIM', name, *pieces) - - # SORTED SET COMMANDS - def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, - gt=None, lt=None): - """ - Set any number of element-name, score pairs to the key ``name``. Pairs - are specified as a dict of element-names keys to score values. - - ``nx`` forces ZADD to only create new elements and not to update - scores for elements that already exist. - - ``xx`` forces ZADD to only update scores of elements that already - exist. New elements will not be added. - - ``ch`` modifies the return value to be the numbers of elements changed. - Changed elements include new elements that were added and elements - whose scores changed. - - ``incr`` modifies ZADD to behave like ZINCRBY. In this mode only a - single element/score pair can be specified and the score is the amount - the existing score will be incremented by. When using this mode the - return value of ZADD will be the new score of the element. - - ``LT`` Only update existing elements if the new score is less than - the current score. This flag doesn't prevent adding new elements. - - ``GT`` Only update existing elements if the new score is greater than - the current score. This flag doesn't prevent adding new elements. - - The return value of ZADD varies based on the mode specified. With no - options, ZADD returns the number of new elements added to the sorted - set. - - ``NX``, ``LT``, and ``GT`` are mutually exclusive options. - See: https://redis.io/commands/ZADD - """ - if not mapping: - raise DataError("ZADD requires at least one element/score pair") - if nx and xx: - raise DataError("ZADD allows either 'nx' or 'xx', not both") - if incr and len(mapping) != 1: - raise DataError("ZADD option 'incr' only works when passing a " - "single element/score pair") - if nx is True and (gt is not None or lt is not None): - raise DataError("Only one of 'nx', 'lt', or 'gr' may be defined.") - - pieces = [] - options = {} - if nx: - pieces.append(b'NX') - if xx: - pieces.append(b'XX') - if ch: - pieces.append(b'CH') - if incr: - pieces.append(b'INCR') - options['as_score'] = True - if gt: - pieces.append(b'GT') - if lt: - pieces.append(b'LT') - for pair in mapping.items(): - pieces.append(pair[1]) - pieces.append(pair[0]) - return self.execute_command('ZADD', name, *pieces, **options) - - def zcard(self, name): - "Return the number of elements in the sorted set ``name``" - return self.execute_command('ZCARD', name) - - def zcount(self, name, min, max): - """ - Returns the number of elements in the sorted set at key ``name`` with - a score between ``min`` and ``max``. - """ - return self.execute_command('ZCOUNT', name, min, max) - - def zdiff(self, keys, withscores=False): - """ - Returns the difference between the first and all successive input - sorted sets provided in ``keys``. - """ - pieces = [len(keys), *keys] - if withscores: - pieces.append("WITHSCORES") - return self.execute_command("ZDIFF", *pieces) - - def zdiffstore(self, dest, keys): - """ - Computes the difference between the first and all successive input - sorted sets provided in ``keys`` and stores the result in ``dest``. - """ - pieces = [len(keys), *keys] - return self.execute_command("ZDIFFSTORE", dest, *pieces) - - def zincrby(self, name, amount, value): - "Increment the score of ``value`` in sorted set ``name`` by ``amount``" - return self.execute_command('ZINCRBY', name, amount, value) - - def zinter(self, keys, aggregate=None, withscores=False): - """ - Return the intersect of multiple sorted sets specified by ``keys``. - With the ``aggregate`` option, it is possible to specify how the - results of the union are aggregated. This option defaults to SUM, - where the score of an element is summed across the inputs where it - exists. When this option is set to either MIN or MAX, the resulting - set will contain the minimum or maximum score of an element across - the inputs where it exists. - """ - return self._zaggregate('ZINTER', None, keys, aggregate, - withscores=withscores) - - def zinterstore(self, dest, keys, aggregate=None): - """ - Intersect multiple sorted sets specified by ``keys`` into a new - sorted set, ``dest``. Scores in the destination will be aggregated - based on the ``aggregate``. This option defaults to SUM, where the - score of an element is summed across the inputs where it exists. - When this option is set to either MIN or MAX, the resulting set will - contain the minimum or maximum score of an element across the inputs - where it exists. - """ - return self._zaggregate('ZINTERSTORE', dest, keys, aggregate) - - def zlexcount(self, name, min, max): - """ - Return the number of items in the sorted set ``name`` between the - lexicographical range ``min`` and ``max``. - """ - return self.execute_command('ZLEXCOUNT', name, min, max) - - def zpopmax(self, name, count=None): - """ - Remove and return up to ``count`` members with the highest scores - from the sorted set ``name``. - """ - args = (count is not None) and [count] or [] - options = { - 'withscores': True - } - return self.execute_command('ZPOPMAX', name, *args, **options) - - def zpopmin(self, name, count=None): - """ - Remove and return up to ``count`` members with the lowest scores - from the sorted set ``name``. - """ - args = (count is not None) and [count] or [] - options = { - 'withscores': True - } - return self.execute_command('ZPOPMIN', name, *args, **options) - - def zrandmember(self, key, count=None, withscores=False): - """ - Return a random element from the sorted set value stored at key. - - ``count`` if the argument is positive, return an array of distinct - fields. If called with a negative count, the behavior changes and - the command is allowed to return the same field multiple times. - In this case, the number of returned fields is the absolute value - of the specified count. - - ``withscores`` The optional WITHSCORES modifier changes the reply so it - includes the respective scores of the randomly selected elements from - the sorted set. - """ - params = [] - if count is not None: - params.append(count) - if withscores: - params.append("WITHSCORES") - - return self.execute_command("ZRANDMEMBER", key, *params) - - def bzpopmax(self, keys, timeout=0): - """ - ZPOPMAX a value off of the first non-empty sorted set - named in the ``keys`` list. - - If none of the sorted sets in ``keys`` has a value to ZPOPMAX, - then block for ``timeout`` seconds, or until a member gets added - to one of the sorted sets. - - If timeout is 0, then block indefinitely. - """ - if timeout is None: - timeout = 0 - keys = list_or_args(keys, None) - keys.append(timeout) - return self.execute_command('BZPOPMAX', *keys) - - def bzpopmin(self, keys, timeout=0): - """ - ZPOPMIN a value off of the first non-empty sorted set - named in the ``keys`` list. - - If none of the sorted sets in ``keys`` has a value to ZPOPMIN, - then block for ``timeout`` seconds, or until a member gets added - to one of the sorted sets. - - If timeout is 0, then block indefinitely. - """ - if timeout is None: - timeout = 0 - keys = list_or_args(keys, None) - keys.append(timeout) - return self.execute_command('BZPOPMIN', *keys) - - def zrange(self, name, start, end, desc=False, withscores=False, - score_cast_func=float): - """ - Return a range of values from sorted set ``name`` between - ``start`` and ``end`` sorted in ascending order. - - ``start`` and ``end`` can be negative, indicating the end of the range. - - ``desc`` a boolean indicating whether to sort the results descendingly - - ``withscores`` indicates to return the scores along with the values. - The return type is a list of (value, score) pairs - - ``score_cast_func`` a callable used to cast the score return value - """ - if desc: - return self.zrevrange(name, start, end, withscores, - score_cast_func) - pieces = ['ZRANGE', name, start, end] - if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } - return self.execute_command(*pieces, **options) - - def zrangestore(self, dest, name, start, end): - """ - Stores in ``dest`` the result of a range of values from sorted set - ``name`` between ``start`` and ``end`` sorted in ascending order. - - ``start`` and ``end`` can be negative, indicating the end of the range. - """ - return self.execute_command('ZRANGESTORE', dest, name, start, end) - - def zrangebylex(self, name, min, max, start=None, num=None): - """ - Return the lexicographical range of values from sorted set ``name`` - between ``min`` and ``max``. - - If ``start`` and ``num`` are specified, then return a slice of the - range. - """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZRANGEBYLEX', name, min, max] - if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) - return self.execute_command(*pieces) - - def zrevrangebylex(self, name, max, min, start=None, num=None): - """ - Return the reversed lexicographical range of values from sorted set - ``name`` between ``max`` and ``min``. - - If ``start`` and ``num`` are specified, then return a slice of the - range. - """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZREVRANGEBYLEX', name, max, min] - if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) - return self.execute_command(*pieces) - - def zrangebyscore(self, name, min, max, start=None, num=None, - withscores=False, score_cast_func=float): - """ - Return a range of values from the sorted set ``name`` with scores - between ``min`` and ``max``. - - If ``start`` and ``num`` are specified, then return a slice - of the range. - - ``withscores`` indicates to return the scores along with the values. - The return type is a list of (value, score) pairs - - `score_cast_func`` a callable used to cast the score return value - """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZRANGEBYSCORE', name, min, max] - if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) - if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } - return self.execute_command(*pieces, **options) - - def zrank(self, name, value): - """ - Returns a 0-based value indicating the rank of ``value`` in sorted set - ``name`` - """ - return self.execute_command('ZRANK', name, value) - - def zrem(self, name, *values): - "Remove member ``values`` from sorted set ``name``" - return self.execute_command('ZREM', name, *values) - - def zremrangebylex(self, name, min, max): - """ - Remove all elements in the sorted set ``name`` between the - lexicographical range specified by ``min`` and ``max``. - - Returns the number of elements removed. - """ - return self.execute_command('ZREMRANGEBYLEX', name, min, max) - - def zremrangebyrank(self, name, min, max): - """ - Remove all elements in the sorted set ``name`` with ranks between - ``min`` and ``max``. Values are 0-based, ordered from smallest score - to largest. Values can be negative indicating the highest scores. - Returns the number of elements removed - """ - return self.execute_command('ZREMRANGEBYRANK', name, min, max) - - def zremrangebyscore(self, name, min, max): - """ - Remove all elements in the sorted set ``name`` with scores - between ``min`` and ``max``. Returns the number of elements removed. - """ - return self.execute_command('ZREMRANGEBYSCORE', name, min, max) - - def zrevrange(self, name, start, end, withscores=False, - score_cast_func=float): - """ - Return a range of values from sorted set ``name`` between - ``start`` and ``end`` sorted in descending order. - - ``start`` and ``end`` can be negative, indicating the end of the range. - - ``withscores`` indicates to return the scores along with the values - The return type is a list of (value, score) pairs - - ``score_cast_func`` a callable used to cast the score return value - """ - pieces = ['ZREVRANGE', name, start, end] - if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } - return self.execute_command(*pieces, **options) - - def zrevrangebyscore(self, name, max, min, start=None, num=None, - withscores=False, score_cast_func=float): - """ - Return a range of values from the sorted set ``name`` with scores - between ``min`` and ``max`` in descending order. - - If ``start`` and ``num`` are specified, then return a slice - of the range. - - ``withscores`` indicates to return the scores along with the values. - The return type is a list of (value, score) pairs - - ``score_cast_func`` a callable used to cast the score return value - """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZREVRANGEBYSCORE', name, max, min] - if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) - if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } - return self.execute_command(*pieces, **options) - - def zrevrank(self, name, value): - """ - Returns a 0-based value indicating the descending rank of - ``value`` in sorted set ``name`` - """ - return self.execute_command('ZREVRANK', name, value) - - def zscore(self, name, value): - "Return the score of element ``value`` in sorted set ``name``" - return self.execute_command('ZSCORE', name, value) - - def zunion(self, keys, aggregate=None, withscores=False): - """ - Return the union of multiple sorted sets specified by ``keys``. - ``keys`` can be provided as dictionary of keys and their weights. - Scores will be aggregated based on the ``aggregate``, or SUM if - none is provided. - """ - return self._zaggregate('ZUNION', None, keys, aggregate, - withscores=withscores) - - def zunionstore(self, dest, keys, aggregate=None): - """ - Union multiple sorted sets specified by ``keys`` into - a new sorted set, ``dest``. Scores in the destination will be - aggregated based on the ``aggregate``, or SUM if none is provided. - """ - return self._zaggregate('ZUNIONSTORE', dest, keys, aggregate) - - def _zaggregate(self, command, dest, keys, aggregate=None, - **options): - pieces = [command] - if dest is not None: - pieces.append(dest) - pieces.append(len(keys)) - if isinstance(keys, dict): - keys, weights = keys.keys(), keys.values() - else: - weights = None - pieces.extend(keys) - if weights: - pieces.append(b'WEIGHTS') - pieces.extend(weights) - if aggregate: - if aggregate.upper() in ['SUM', 'MIN', 'MAX']: - pieces.append(b'AGGREGATE') - pieces.append(aggregate) - else: - raise DataError("aggregate can be sum, min or max.") - if options.get('withscores', False): - pieces.append(b'WITHSCORES') - return self.execute_command(*pieces, **options) - - # HYPERLOGLOG COMMANDS - def pfadd(self, name, *values): - "Adds the specified elements to the specified HyperLogLog." - return self.execute_command('PFADD', name, *values) - - def pfcount(self, *sources): - """ - Return the approximated cardinality of - the set observed by the HyperLogLog at key(s). - """ - return self.execute_command('PFCOUNT', *sources) - - def pfmerge(self, dest, *sources): - "Merge N different HyperLogLogs into a single one." - return self.execute_command('PFMERGE', dest, *sources) - - # HASH COMMANDS - def hdel(self, name, *keys): - "Delete ``keys`` from hash ``name``" - return self.execute_command('HDEL', name, *keys) - - def hexists(self, name, key): - "Returns a boolean indicating if ``key`` exists within hash ``name``" - return self.execute_command('HEXISTS', name, key) - - def hget(self, name, key): - "Return the value of ``key`` within the hash ``name``" - return self.execute_command('HGET', name, key) - - def hgetall(self, name): - "Return a Python dict of the hash's name/value pairs" - return self.execute_command('HGETALL', name) - - def hincrby(self, name, key, amount=1): - "Increment the value of ``key`` in hash ``name`` by ``amount``" - return self.execute_command('HINCRBY', name, key, amount) - - def hincrbyfloat(self, name, key, amount=1.0): - """ - Increment the value of ``key`` in hash ``name`` by floating ``amount`` - """ - return self.execute_command('HINCRBYFLOAT', name, key, amount) - - def hkeys(self, name): - "Return the list of keys within hash ``name``" - return self.execute_command('HKEYS', name) - - def hlen(self, name): - "Return the number of elements in hash ``name``" - return self.execute_command('HLEN', name) - - def hset(self, name, key=None, value=None, mapping=None): - """ - Set ``key`` to ``value`` within hash ``name``, - ``mapping`` accepts a dict of key/value pairs that will be - added to hash ``name``. - Returns the number of fields that were added. - """ - if key is None and not mapping: - raise DataError("'hset' with no key value pairs") - items = [] - if key is not None: - items.extend((key, value)) - if mapping: - for pair in mapping.items(): - items.extend(pair) - - return self.execute_command('HSET', name, *items) - - def hsetnx(self, name, key, value): - """ - Set ``key`` to ``value`` within hash ``name`` if ``key`` does not - exist. Returns 1 if HSETNX created a field, otherwise 0. - """ - return self.execute_command('HSETNX', name, key, value) - - def hmset(self, name, mapping): - """ - Set key to value within hash ``name`` for each corresponding - key and value from the ``mapping`` dict. - """ - warnings.warn( - '%s.hmset() is deprecated. Use %s.hset() instead.' - % (self.__class__.__name__, self.__class__.__name__), - DeprecationWarning, - stacklevel=2, - ) - if not mapping: - raise DataError("'hmset' with 'mapping' of length 0") - items = [] - for pair in mapping.items(): - items.extend(pair) - return self.execute_command('HMSET', name, *items) - - def hmget(self, name, keys, *args): - "Returns a list of values ordered identically to ``keys``" - args = list_or_args(keys, args) - return self.execute_command('HMGET', name, *args) - - def hvals(self, name): - "Return the list of values within hash ``name``" - return self.execute_command('HVALS', name) - - def hstrlen(self, name, key): - """ - Return the number of bytes stored in the value of ``key`` - within hash ``name`` - """ - return self.execute_command('HSTRLEN', name, key) - - def publish(self, channel, message): - """ - Publish ``message`` on ``channel``. - Returns the number of subscribers the message was delivered to. - """ - return self.execute_command('PUBLISH', channel, message) - - def pubsub_channels(self, pattern='*'): - """ - Return a list of channels that have at least one subscriber - """ - return self.execute_command('PUBSUB CHANNELS', pattern) - - def pubsub_numpat(self): - """ - Returns the number of subscriptions to patterns - """ - return self.execute_command('PUBSUB NUMPAT') - - def pubsub_numsub(self, *args): - """ - Return a list of (channel, number of subscribers) tuples - for each channel given in ``*args`` - """ - return self.execute_command('PUBSUB NUMSUB', *args) - - def cluster(self, cluster_arg, *args): - return self.execute_command('CLUSTER %s' % cluster_arg.upper(), *args) - - def eval(self, script, numkeys, *keys_and_args): - """ - Execute the Lua ``script``, specifying the ``numkeys`` the script - will touch and the key names and argument values in ``keys_and_args``. - Returns the result of the script. - - In practice, use the object returned by ``register_script``. This - function exists purely for Redis API completion. - """ - return self.execute_command('EVAL', script, numkeys, *keys_and_args) - - def evalsha(self, sha, numkeys, *keys_and_args): - """ - Use the ``sha`` to execute a Lua script already registered via EVAL - or SCRIPT LOAD. Specify the ``numkeys`` the script will touch and the - key names and argument values in ``keys_and_args``. Returns the result - of the script. - - In practice, use the object returned by ``register_script``. This - function exists purely for Redis API completion. - """ - return self.execute_command('EVALSHA', sha, numkeys, *keys_and_args) - - def script_exists(self, *args): - """ - Check if a script exists in the script cache by specifying the SHAs of - each script as ``args``. Returns a list of boolean values indicating if - if each already script exists in the cache. - """ - return self.execute_command('SCRIPT EXISTS', *args) - - def script_flush(self): - "Flush all scripts from the script cache" - return self.execute_command('SCRIPT FLUSH') - - def script_kill(self): - "Kill the currently executing Lua script" - return self.execute_command('SCRIPT KILL') - - def script_load(self, script): - "Load a Lua ``script`` into the script cache. Returns the SHA." - return self.execute_command('SCRIPT LOAD', script) - - def register_script(self, script): - """ - Register a Lua ``script`` specifying the ``keys`` it will touch. - Returns a Script object that is callable and hides the complexity of - deal with scripts, keys, and shas. This is the preferred way to work - with Lua scripts. - """ - return Script(self, script) - - # GEO COMMANDS - def geoadd(self, name, *values): - """ - Add the specified geospatial items to the specified key identified - by the ``name`` argument. The Geospatial items are given as ordered - members of the ``values`` argument, each item or place is formed by - the triad longitude, latitude and name. - """ - if len(values) % 3 != 0: - raise DataError("GEOADD requires places with lon, lat and name" - " values") - return self.execute_command('GEOADD', name, *values) - - def geodist(self, name, place1, place2, unit=None): - """ - Return the distance between ``place1`` and ``place2`` members of the - ``name`` key. - The units must be one of the following : m, km mi, ft. By default - meters are used. - """ - pieces = [name, place1, place2] - if unit and unit not in ('m', 'km', 'mi', 'ft'): - raise DataError("GEODIST invalid unit") - elif unit: - pieces.append(unit) - return self.execute_command('GEODIST', *pieces) - - def geohash(self, name, *values): - """ - Return the geo hash string for each item of ``values`` members of - the specified key identified by the ``name`` argument. - """ - return self.execute_command('GEOHASH', name, *values) - - def geopos(self, name, *values): - """ - Return the positions of each item of ``values`` as members of - the specified key identified by the ``name`` argument. Each position - is represented by the pairs lon and lat. - """ - return self.execute_command('GEOPOS', name, *values) - - def georadius(self, name, longitude, latitude, radius, unit=None, - withdist=False, withcoord=False, withhash=False, count=None, - sort=None, store=None, store_dist=None): - """ - Return the members of the specified key identified by the - ``name`` argument which are within the borders of the area specified - with the ``latitude`` and ``longitude`` location and the maximum - distance from the center specified by the ``radius`` value. - - The units must be one of the following : m, km mi, ft. By default - - ``withdist`` indicates to return the distances of each place. - - ``withcoord`` indicates to return the latitude and longitude of - each place. - - ``withhash`` indicates to return the geohash string of each place. - - ``count`` indicates to return the number of elements up to N. - - ``sort`` indicates to return the places in a sorted way, ASC for - nearest to fairest and DESC for fairest to nearest. - - ``store`` indicates to save the places names in a sorted set named - with a specific key, each element of the destination sorted set is - populated with the score got from the original geo sorted set. - - ``store_dist`` indicates to save the places names in a sorted set - named with a specific key, instead of ``store`` the sorted set - destination score is set with the distance. - """ - return self._georadiusgeneric('GEORADIUS', - name, longitude, latitude, radius, - unit=unit, withdist=withdist, - withcoord=withcoord, withhash=withhash, - count=count, sort=sort, store=store, - store_dist=store_dist) - - def georadiusbymember(self, name, member, radius, unit=None, - withdist=False, withcoord=False, withhash=False, - count=None, sort=None, store=None, store_dist=None): - """ - This command is exactly like ``georadius`` with the sole difference - that instead of taking, as the center of the area to query, a longitude - and latitude value, it takes the name of a member already existing - inside the geospatial index represented by the sorted set. - """ - return self._georadiusgeneric('GEORADIUSBYMEMBER', - name, member, radius, unit=unit, - withdist=withdist, withcoord=withcoord, - withhash=withhash, count=count, - sort=sort, store=store, - store_dist=store_dist) - - def _georadiusgeneric(self, command, *args, **kwargs): - pieces = list(args) - if kwargs['unit'] and kwargs['unit'] not in ('m', 'km', 'mi', 'ft'): - raise DataError("GEORADIUS invalid unit") - elif kwargs['unit']: - pieces.append(kwargs['unit']) - else: - pieces.append('m',) - - for arg_name, byte_repr in ( - ('withdist', b'WITHDIST'), - ('withcoord', b'WITHCOORD'), - ('withhash', b'WITHHASH')): - if kwargs[arg_name]: - pieces.append(byte_repr) - - if kwargs['count']: - pieces.extend([b'COUNT', kwargs['count']]) - - if kwargs['sort']: - if kwargs['sort'] == 'ASC': - pieces.append(b'ASC') - elif kwargs['sort'] == 'DESC': - pieces.append(b'DESC') - else: - raise DataError("GEORADIUS invalid sort") - - if kwargs['store'] and kwargs['store_dist']: - raise DataError("GEORADIUS store and store_dist cant be set" - " together") - - if kwargs['store']: - pieces.extend([b'STORE', kwargs['store']]) - - if kwargs['store_dist']: - pieces.extend([b'STOREDIST', kwargs['store_dist']]) - - return self.execute_command(command, *pieces, **kwargs) - - # MODULE COMMANDS - def module_load(self, path): - """ - Loads the module from ``path``. - Raises ``ModuleError`` if a module is not found at ``path``. - """ - return self.execute_command('MODULE LOAD', path) - - def module_unload(self, name): - """ - Unloads the module ``name``. - Raises ``ModuleError`` if ``name`` is not in loaded modules. - """ - return self.execute_command('MODULE UNLOAD', name) - - def module_list(self): - """ - Returns a list of dictionaries containing the name and version of - all loaded modules. - """ - return self.execute_command('MODULE LIST') - StrictRedis = Redis diff --git a/redis/commands.py b/redis/commands.py new file mode 100644 index 0000000000..003b0f1bcc --- /dev/null +++ b/redis/commands.py @@ -0,0 +1,2995 @@ +import datetime +import time +import warnings +import hashlib + +from redis.exceptions import ( + ConnectionError, + DataError, + NoScriptError, + RedisError, +) + + +def list_or_args(keys, args): + # returns a single new list combining keys and args + try: + iter(keys) + # a string or bytes instance can be iterated, but indicates + # keys wasn't passed as a list + if isinstance(keys, (bytes, str)): + keys = [keys] + else: + keys = list(keys) + except TypeError: + keys = [keys] + if args: + keys.extend(args) + return keys + + +class Commands: + """ + A class containing all of the implemented redis commands. This class is + to be used as a mixin. + """ + + # SERVER INFORMATION + + # ACL methods + def acl_cat(self, category=None): + """ + Returns a list of categories or commands within a category. + + If ``category`` is not supplied, returns a list of all categories. + If ``category`` is supplied, returns a list of all commands within + that category. + """ + pieces = [category] if category else [] + return self.execute_command('ACL CAT', *pieces) + + def acl_deluser(self, username): + "Delete the ACL for the specified ``username``" + return self.execute_command('ACL DELUSER', username) + + def acl_genpass(self): + "Generate a random password value" + return self.execute_command('ACL GENPASS') + + def acl_getuser(self, username): + """ + Get the ACL details for the specified ``username``. + + If ``username`` does not exist, return None + """ + return self.execute_command('ACL GETUSER', username) + + def acl_list(self): + "Return a list of all ACLs on the server" + return self.execute_command('ACL LIST') + + def acl_log(self, count=None): + """ + Get ACL logs as a list. + :param int count: Get logs[0:count]. + :rtype: List. + """ + args = [] + if count is not None: + if not isinstance(count, int): + raise DataError('ACL LOG count must be an ' + 'integer') + args.append(count) + + return self.execute_command('ACL LOG', *args) + + def acl_log_reset(self): + """ + Reset ACL logs. + :rtype: Boolean. + """ + args = [b'RESET'] + return self.execute_command('ACL LOG', *args) + + def acl_load(self): + """ + Load ACL rules from the configured ``aclfile``. + + Note that the server must be configured with the ``aclfile`` + directive to be able to load ACL rules from an aclfile. + """ + return self.execute_command('ACL LOAD') + + def acl_save(self): + """ + Save ACL rules to the configured ``aclfile``. + + Note that the server must be configured with the ``aclfile`` + directive to be able to save ACL rules to an aclfile. + """ + return self.execute_command('ACL SAVE') + + def acl_setuser(self, username, enabled=False, nopass=False, + passwords=None, hashed_passwords=None, categories=None, + commands=None, keys=None, reset=False, reset_keys=False, + reset_passwords=False): + """ + Create or update an ACL user. + + Create or update the ACL for ``username``. If the user already exists, + the existing ACL is completely overwritten and replaced with the + specified values. + + ``enabled`` is a boolean indicating whether the user should be allowed + to authenticate or not. Defaults to ``False``. + + ``nopass`` is a boolean indicating whether the can authenticate without + a password. This cannot be True if ``passwords`` are also specified. + + ``passwords`` if specified is a list of plain text passwords + to add to or remove from the user. Each password must be prefixed with + a '+' to add or a '-' to remove. For convenience, the value of + ``passwords`` can be a simple prefixed string when adding or + removing a single password. + + ``hashed_passwords`` if specified is a list of SHA-256 hashed passwords + to add to or remove from the user. Each hashed password must be + prefixed with a '+' to add or a '-' to remove. For convenience, + the value of ``hashed_passwords`` can be a simple prefixed string when + adding or removing a single password. + + ``categories`` if specified is a list of strings representing category + permissions. Each string must be prefixed with either a '+' to add the + category permission or a '-' to remove the category permission. + + ``commands`` if specified is a list of strings representing command + permissions. Each string must be prefixed with either a '+' to add the + command permission or a '-' to remove the command permission. + + ``keys`` if specified is a list of key patterns to grant the user + access to. Keys patterns allow '*' to support wildcard matching. For + example, '*' grants access to all keys while 'cache:*' grants access + to all keys that are prefixed with 'cache:'. ``keys`` should not be + prefixed with a '~'. + + ``reset`` is a boolean indicating whether the user should be fully + reset prior to applying the new ACL. Setting this to True will + remove all existing passwords, flags and privileges from the user and + then apply the specified rules. If this is False, the user's existing + passwords, flags and privileges will be kept and any new specified + rules will be applied on top. + + ``reset_keys`` is a boolean indicating whether the user's key + permissions should be reset prior to applying any new key permissions + specified in ``keys``. If this is False, the user's existing + key permissions will be kept and any new specified key permissions + will be applied on top. + + ``reset_passwords`` is a boolean indicating whether to remove all + existing passwords and the 'nopass' flag from the user prior to + applying any new passwords specified in 'passwords' or + 'hashed_passwords'. If this is False, the user's existing passwords + and 'nopass' status will be kept and any new specified passwords + or hashed_passwords will be applied on top. + """ + encoder = self.connection_pool.get_encoder() + pieces = [username] + + if reset: + pieces.append(b'reset') + + if reset_keys: + pieces.append(b'resetkeys') + + if reset_passwords: + pieces.append(b'resetpass') + + if enabled: + pieces.append(b'on') + else: + pieces.append(b'off') + + if (passwords or hashed_passwords) and nopass: + raise DataError('Cannot set \'nopass\' and supply ' + '\'passwords\' or \'hashed_passwords\'') + + if passwords: + # as most users will have only one password, allow remove_passwords + # to be specified as a simple string or a list + passwords = list_or_args(passwords, []) + for i, password in enumerate(passwords): + password = encoder.encode(password) + if password.startswith(b'+'): + pieces.append(b'>%s' % password[1:]) + elif password.startswith(b'-'): + pieces.append(b'<%s' % password[1:]) + else: + raise DataError('Password %d must be prefixeed with a ' + '"+" to add or a "-" to remove' % i) + + if hashed_passwords: + # as most users will have only one password, allow remove_passwords + # to be specified as a simple string or a list + hashed_passwords = list_or_args(hashed_passwords, []) + for i, hashed_password in enumerate(hashed_passwords): + hashed_password = encoder.encode(hashed_password) + if hashed_password.startswith(b'+'): + pieces.append(b'#%s' % hashed_password[1:]) + elif hashed_password.startswith(b'-'): + pieces.append(b'!%s' % hashed_password[1:]) + else: + raise DataError('Hashed %d password must be prefixeed ' + 'with a "+" to add or a "-" to remove' % i) + + if nopass: + pieces.append(b'nopass') + + if categories: + for category in categories: + category = encoder.encode(category) + # categories can be prefixed with one of (+@, +, -@, -) + if category.startswith(b'+@'): + pieces.append(category) + elif category.startswith(b'+'): + pieces.append(b'+@%s' % category[1:]) + elif category.startswith(b'-@'): + pieces.append(category) + elif category.startswith(b'-'): + pieces.append(b'-@%s' % category[1:]) + else: + raise DataError('Category "%s" must be prefixed with ' + '"+" or "-"' + % encoder.decode(category, force=True)) + if commands: + for cmd in commands: + cmd = encoder.encode(cmd) + if not cmd.startswith(b'+') and not cmd.startswith(b'-'): + raise DataError('Command "%s" must be prefixed with ' + '"+" or "-"' + % encoder.decode(cmd, force=True)) + pieces.append(cmd) + + if keys: + for key in keys: + key = encoder.encode(key) + pieces.append(b'~%s' % key) + + return self.execute_command('ACL SETUSER', *pieces) + + def acl_users(self): + "Returns a list of all registered users on the server." + return self.execute_command('ACL USERS') + + def acl_whoami(self): + "Get the username for the current connection" + return self.execute_command('ACL WHOAMI') + + def bgrewriteaof(self): + "Tell the Redis server to rewrite the AOF file from data in memory." + return self.execute_command('BGREWRITEAOF') + + def bgsave(self): + """ + Tell the Redis server to save its data to disk. Unlike save(), + this method is asynchronous and returns immediately. + """ + return self.execute_command('BGSAVE') + + def client_kill(self, address): + "Disconnects the client at ``address`` (ip:port)" + return self.execute_command('CLIENT KILL', address) + + def client_kill_filter(self, _id=None, _type=None, addr=None, + skipme=None, laddr=None): + """ + Disconnects client(s) using a variety of filter options + :param id: Kills a client by its unique ID field + :param type: Kills a client by type where type is one of 'normal', + 'master', 'slave' or 'pubsub' + :param addr: Kills a client by its 'address:port' + :param skipme: If True, then the client calling the command + :param laddr: Kills a cient by its 'local (bind) address:port' + will not get killed even if it is identified by one of the filter + options. If skipme is not provided, the server defaults to skipme=True + """ + args = [] + if _type is not None: + client_types = ('normal', 'master', 'slave', 'pubsub') + if str(_type).lower() not in client_types: + raise DataError("CLIENT KILL type must be one of %r" % ( + client_types,)) + args.extend((b'TYPE', _type)) + if skipme is not None: + if not isinstance(skipme, bool): + raise DataError("CLIENT KILL skipme must be a bool") + if skipme: + args.extend((b'SKIPME', b'YES')) + else: + args.extend((b'SKIPME', b'NO')) + if _id is not None: + args.extend((b'ID', _id)) + if addr is not None: + args.extend((b'ADDR', addr)) + if laddr is not None: + args.extend((b'LADDR', laddr)) + if not args: + raise DataError("CLIENT KILL ... ... " + " must specify at least one filter") + return self.execute_command('CLIENT KILL', *args) + + def client_info(self): + """ + Returns information and statistics about the current + client connection. + """ + return self.execute_command('CLIENT INFO') + + def client_list(self, _type=None, client_id=None): + """ + Returns a list of currently connected clients. + If type of client specified, only that type will be returned. + :param _type: optional. one of the client types (normal, master, + replica, pubsub) + """ + "Returns a list of currently connected clients" + args = [] + if _type is not None: + client_types = ('normal', 'master', 'replica', 'pubsub') + if str(_type).lower() not in client_types: + raise DataError("CLIENT LIST _type must be one of %r" % ( + client_types,)) + args.append(b'TYPE') + args.append(_type) + if client_id is not None: + args.append(b"ID") + args.append(client_id) + return self.execute_command('CLIENT LIST', *args) + + def client_getname(self): + "Returns the current connection name" + return self.execute_command('CLIENT GETNAME') + + def client_id(self): + "Returns the current connection id" + return self.execute_command('CLIENT ID') + + def client_setname(self, name): + "Sets the current connection name" + return self.execute_command('CLIENT SETNAME', name) + + def client_unblock(self, client_id, error=False): + """ + Unblocks a connection by its client id. + If ``error`` is True, unblocks the client with a special error message. + If ``error`` is False (default), the client is unblocked using the + regular timeout mechanism. + """ + args = ['CLIENT UNBLOCK', int(client_id)] + if error: + args.append(b'ERROR') + return self.execute_command(*args) + + def client_pause(self, timeout): + """ + Suspend all the Redis clients for the specified amount of time + :param timeout: milliseconds to pause clients + """ + if not isinstance(timeout, int): + raise DataError("CLIENT PAUSE timeout must be an integer") + return self.execute_command('CLIENT PAUSE', str(timeout)) + + def client_unpause(self): + """ + Unpause all redis clients + """ + return self.execute_command('CLIENT UNPAUSE') + + def readwrite(self): + "Disables read queries for a connection to a Redis Cluster slave node" + return self.execute_command('READWRITE') + + def readonly(self): + "Enables read queries for a connection to a Redis Cluster replica node" + return self.execute_command('READONLY') + + def config_get(self, pattern="*"): + "Return a dictionary of configuration based on the ``pattern``" + return self.execute_command('CONFIG GET', pattern) + + def config_set(self, name, value): + "Set config item ``name`` with ``value``" + return self.execute_command('CONFIG SET', name, value) + + def config_resetstat(self): + "Reset runtime statistics" + return self.execute_command('CONFIG RESETSTAT') + + def config_rewrite(self): + "Rewrite config file with the minimal change to reflect running config" + return self.execute_command('CONFIG REWRITE') + + def dbsize(self): + "Returns the number of keys in the current database" + return self.execute_command('DBSIZE') + + def debug_object(self, key): + "Returns version specific meta information about a given key" + return self.execute_command('DEBUG OBJECT', key) + + def echo(self, value): + "Echo the string back from the server" + return self.execute_command('ECHO', value) + + def flushall(self, asynchronous=False): + """ + Delete all keys in all databases on the current host. + + ``asynchronous`` indicates whether the operation is + executed asynchronously by the server. + """ + args = [] + if asynchronous: + args.append(b'ASYNC') + return self.execute_command('FLUSHALL', *args) + + def flushdb(self, asynchronous=False): + """ + Delete all keys in the current database. + + ``asynchronous`` indicates whether the operation is + executed asynchronously by the server. + """ + args = [] + if asynchronous: + args.append(b'ASYNC') + return self.execute_command('FLUSHDB', *args) + + def swapdb(self, first, second): + "Swap two databases" + return self.execute_command('SWAPDB', first, second) + + def info(self, section=None): + """ + Returns a dictionary containing information about the Redis server + + The ``section`` option can be used to select a specific section + of information + + The section option is not supported by older versions of Redis Server, + and will generate ResponseError + """ + if section is None: + return self.execute_command('INFO') + else: + return self.execute_command('INFO', section) + + def lastsave(self): + """ + Return a Python datetime object representing the last time the + Redis database was saved to disk + """ + return self.execute_command('LASTSAVE') + + def migrate(self, host, port, keys, destination_db, timeout, + copy=False, replace=False, auth=None): + """ + Migrate 1 or more keys from the current Redis server to a different + server specified by the ``host``, ``port`` and ``destination_db``. + + The ``timeout``, specified in milliseconds, indicates the maximum + time the connection between the two servers can be idle before the + command is interrupted. + + If ``copy`` is True, the specified ``keys`` are NOT deleted from + the source server. + + If ``replace`` is True, this operation will overwrite the keys + on the destination server if they exist. + + If ``auth`` is specified, authenticate to the destination server with + the password provided. + """ + keys = list_or_args(keys, []) + if not keys: + raise DataError('MIGRATE requires at least one key') + pieces = [] + if copy: + pieces.append(b'COPY') + if replace: + pieces.append(b'REPLACE') + if auth: + pieces.append(b'AUTH') + pieces.append(auth) + pieces.append(b'KEYS') + pieces.extend(keys) + return self.execute_command('MIGRATE', host, port, '', destination_db, + timeout, *pieces) + + def object(self, infotype, key): + "Return the encoding, idletime, or refcount about the key" + return self.execute_command('OBJECT', infotype, key, infotype=infotype) + + def memory_stats(self): + "Return a dictionary of memory stats" + return self.execute_command('MEMORY STATS') + + def memory_usage(self, key, samples=None): + """ + Return the total memory usage for key, its value and associated + administrative overheads. + + For nested data structures, ``samples`` is the number of elements to + sample. If left unspecified, the server's default is 5. Use 0 to sample + all elements. + """ + args = [] + if isinstance(samples, int): + args.extend([b'SAMPLES', samples]) + return self.execute_command('MEMORY USAGE', key, *args) + + def memory_purge(self): + "Attempts to purge dirty pages for reclamation by allocator" + return self.execute_command('MEMORY PURGE') + + def ping(self): + "Ping the Redis server" + return self.execute_command('PING') + + def save(self): + """ + Tell the Redis server to save its data to disk, + blocking until the save is complete + """ + return self.execute_command('SAVE') + + def shutdown(self, save=False, nosave=False): + """Shutdown the Redis server. If Redis has persistence configured, + data will be flushed before shutdown. If the "save" option is set, + a data flush will be attempted even if there is no persistence + configured. If the "nosave" option is set, no data flush will be + attempted. The "save" and "nosave" options cannot both be set. + """ + if save and nosave: + raise DataError('SHUTDOWN save and nosave cannot both be set') + args = ['SHUTDOWN'] + if save: + args.append('SAVE') + if nosave: + args.append('NOSAVE') + try: + self.execute_command(*args) + except ConnectionError: + # a ConnectionError here is expected + return + raise RedisError("SHUTDOWN seems to have failed.") + + def slaveof(self, host=None, port=None): + """ + Set the server to be a replicated slave of the instance identified + by the ``host`` and ``port``. If called without arguments, the + instance is promoted to a master instead. + """ + if host is None and port is None: + return self.execute_command('SLAVEOF', b'NO', b'ONE') + return self.execute_command('SLAVEOF', host, port) + + def slowlog_get(self, num=None): + """ + Get the entries from the slowlog. If ``num`` is specified, get the + most recent ``num`` items. + """ + args = ['SLOWLOG GET'] + if num is not None: + args.append(num) + decode_responses = self.connection_pool.connection_kwargs.get( + 'decode_responses', False) + return self.execute_command(*args, decode_responses=decode_responses) + + def slowlog_len(self): + "Get the number of items in the slowlog" + return self.execute_command('SLOWLOG LEN') + + def slowlog_reset(self): + "Remove all items in the slowlog" + return self.execute_command('SLOWLOG RESET') + + def time(self): + """ + Returns the server time as a 2-item tuple of ints: + (seconds since epoch, microseconds into this second). + """ + return self.execute_command('TIME') + + def wait(self, num_replicas, timeout): + """ + Redis synchronous replication + That returns the number of replicas that processed the query when + we finally have at least ``num_replicas``, or when the ``timeout`` was + reached. + """ + return self.execute_command('WAIT', num_replicas, timeout) + + # BASIC KEY COMMANDS + def append(self, key, value): + """ + Appends the string ``value`` to the value at ``key``. If ``key`` + doesn't already exist, create it with a value of ``value``. + Returns the new length of the value at ``key``. + """ + return self.execute_command('APPEND', key, value) + + def bitcount(self, key, start=None, end=None): + """ + Returns the count of set bits in the value of ``key``. Optional + ``start`` and ``end`` parameters indicate which bytes to consider + """ + params = [key] + if start is not None and end is not None: + params.append(start) + params.append(end) + elif (start is not None and end is None) or \ + (end is not None and start is None): + raise DataError("Both start and end must be specified") + return self.execute_command('BITCOUNT', *params) + + def bitfield(self, key, default_overflow=None): + """ + Return a BitFieldOperation instance to conveniently construct one or + more bitfield operations on ``key``. + """ + return BitFieldOperation(self, key, default_overflow=default_overflow) + + def bitop(self, operation, dest, *keys): + """ + Perform a bitwise operation using ``operation`` between ``keys`` and + store the result in ``dest``. + """ + return self.execute_command('BITOP', operation, dest, *keys) + + def bitpos(self, key, bit, start=None, end=None): + """ + Return the position of the first bit set to 1 or 0 in a string. + ``start`` and ``end`` defines search range. The range is interpreted + as a range of bytes and not a range of bits, so start=0 and end=2 + means to look at the first three bytes. + """ + if bit not in (0, 1): + raise DataError('bit must be 0 or 1') + params = [key, bit] + + start is not None and params.append(start) + + if start is not None and end is not None: + params.append(end) + elif start is None and end is not None: + raise DataError("start argument is not set, " + "when end is specified") + return self.execute_command('BITPOS', *params) + + def copy(self, source, destination, destination_db=None, replace=False): + """ + Copy the value stored in the ``source`` key to the ``destination`` key. + + ``destination_db`` an alternative destination database. By default, + the ``destination`` key is created in the source Redis database. + + ``replace`` whether the ``destination`` key should be removed before + copying the value to it. By default, the value is not copied if + the ``destination`` key already exists. + """ + params = [source, destination] + if destination_db is not None: + params.extend(["DB", destination_db]) + if replace: + params.append("REPLACE") + return self.execute_command('COPY', *params) + + def decr(self, name, amount=1): + """ + Decrements the value of ``key`` by ``amount``. If no key exists, + the value will be initialized as 0 - ``amount`` + """ + # An alias for ``decr()``, because it is already implemented + # as DECRBY redis command. + return self.decrby(name, amount) + + def decrby(self, name, amount=1): + """ + Decrements the value of ``key`` by ``amount``. If no key exists, + the value will be initialized as 0 - ``amount`` + """ + return self.execute_command('DECRBY', name, amount) + + def delete(self, *names): + "Delete one or more keys specified by ``names``" + return self.execute_command('DEL', *names) + + def __delitem__(self, name): + self.delete(name) + + def dump(self, name): + """ + Return a serialized version of the value stored at the specified key. + If key does not exist a nil bulk reply is returned. + """ + return self.execute_command('DUMP', name) + + def exists(self, *names): + "Returns the number of ``names`` that exist" + return self.execute_command('EXISTS', *names) + __contains__ = exists + + def expire(self, name, time): + """ + Set an expire flag on key ``name`` for ``time`` seconds. ``time`` + can be represented by an integer or a Python timedelta object. + """ + if isinstance(time, datetime.timedelta): + time = int(time.total_seconds()) + return self.execute_command('EXPIRE', name, time) + + def expireat(self, name, when): + """ + Set an expire flag on key ``name``. ``when`` can be represented + as an integer indicating unix time or a Python datetime object. + """ + if isinstance(when, datetime.datetime): + when = int(time.mktime(when.timetuple())) + return self.execute_command('EXPIREAT', name, when) + + def get(self, name): + """ + Return the value at key ``name``, or None if the key doesn't exist + """ + return self.execute_command('GET', name) + + def getdel(self, name): + """ + Get the value at key ``name`` and delete the key. This command + is similar to GET, except for the fact that it also deletes + the key on success (if and only if the key's value type + is a string). + """ + return self.execute_command('GETDEL', name) + + def getex(self, name, + ex=None, px=None, exat=None, pxat=None, persist=False): + """ + Get the value of key and optionally set its expiration. + GETEX is similar to GET, but is a write command with + additional options. All time parameters can be given as + datetime.timedelta or integers. + + ``ex`` sets an expire flag on key ``name`` for ``ex`` seconds. + + ``px`` sets an expire flag on key ``name`` for ``px`` milliseconds. + + ``exat`` sets an expire flag on key ``name`` for ``ex`` seconds, + specified in unix time. + + ``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds, + specified in unix time. + + ``persist`` remove the time to live associated with ``name``. + """ + + opset = set([ex, px, exat, pxat]) + if len(opset) > 2 or len(opset) > 1 and persist: + raise DataError("``ex``, ``px``, ``exat``, ``pxat``", + "and ``persist`` are mutually exclusive.") + + pieces = [] + # similar to set command + if ex is not None: + pieces.append('EX') + if isinstance(ex, datetime.timedelta): + ex = int(ex.total_seconds()) + pieces.append(ex) + if px is not None: + pieces.append('PX') + if isinstance(px, datetime.timedelta): + px = int(px.total_seconds() * 1000) + pieces.append(px) + # similar to pexpireat command + if exat is not None: + pieces.append('EXAT') + if isinstance(exat, datetime.datetime): + s = int(exat.microsecond / 1000000) + exat = int(time.mktime(exat.timetuple())) + s + pieces.append(exat) + if pxat is not None: + pieces.append('PXAT') + if isinstance(pxat, datetime.datetime): + ms = int(pxat.microsecond / 1000) + pxat = int(time.mktime(pxat.timetuple())) * 1000 + ms + pieces.append(pxat) + if persist: + pieces.append('PERSIST') + + return self.execute_command('GETEX', name, *pieces) + + def __getitem__(self, name): + """ + Return the value at key ``name``, raises a KeyError if the key + doesn't exist. + """ + value = self.get(name) + if value is not None: + return value + raise KeyError(name) + + def getbit(self, name, offset): + "Returns a boolean indicating the value of ``offset`` in ``name``" + return self.execute_command('GETBIT', name, offset) + + def getrange(self, key, start, end): + """ + Returns the substring of the string value stored at ``key``, + determined by the offsets ``start`` and ``end`` (both are inclusive) + """ + return self.execute_command('GETRANGE', key, start, end) + + def getset(self, name, value): + """ + Sets the value at key ``name`` to ``value`` + and returns the old value at key ``name`` atomically. + + As per Redis 6.2, GETSET is considered deprecated. + Please use SET with GET parameter in new code. + """ + return self.execute_command('GETSET', name, value) + + def incr(self, name, amount=1): + """ + Increments the value of ``key`` by ``amount``. If no key exists, + the value will be initialized as ``amount`` + """ + return self.incrby(name, amount) + + def incrby(self, name, amount=1): + """ + Increments the value of ``key`` by ``amount``. If no key exists, + the value will be initialized as ``amount`` + """ + # An alias for ``incr()``, because it is already implemented + # as INCRBY redis command. + return self.execute_command('INCRBY', name, amount) + + def incrbyfloat(self, name, amount=1.0): + """ + Increments the value at key ``name`` by floating ``amount``. + If no key exists, the value will be initialized as ``amount`` + """ + return self.execute_command('INCRBYFLOAT', name, amount) + + def keys(self, pattern='*'): + "Returns a list of keys matching ``pattern``" + return self.execute_command('KEYS', pattern) + + def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): + """ + Atomically returns and removes the first/last element of a list, + pushing it as the first/last element on the destination list. + Returns the element being popped and pushed. + """ + params = [first_list, second_list, src, dest] + return self.execute_command("LMOVE", *params) + + def blmove(self, first_list, second_list, timeout, + src="LEFT", dest="RIGHT"): + """ + Blocking version of lmove. + """ + params = [first_list, second_list, src, dest, timeout] + return self.execute_command("BLMOVE", *params) + + def mget(self, keys, *args): + """ + Returns a list of values ordered identically to ``keys`` + """ + from redis.client import EMPTY_RESPONSE + args = list_or_args(keys, args) + options = {} + if not args: + options[EMPTY_RESPONSE] = [] + return self.execute_command('MGET', *args, **options) + + def mset(self, mapping): + """ + Sets key/values based on a mapping. Mapping is a dictionary of + key/value pairs. Both keys and values should be strings or types that + can be cast to a string via str(). + """ + items = [] + for pair in mapping.items(): + items.extend(pair) + return self.execute_command('MSET', *items) + + def msetnx(self, mapping): + """ + Sets key/values based on a mapping if none of the keys are already set. + Mapping is a dictionary of key/value pairs. Both keys and values + should be strings or types that can be cast to a string via str(). + Returns a boolean indicating if the operation was successful. + """ + items = [] + for pair in mapping.items(): + items.extend(pair) + return self.execute_command('MSETNX', *items) + + def move(self, name, db): + "Moves the key ``name`` to a different Redis database ``db``" + return self.execute_command('MOVE', name, db) + + def persist(self, name): + "Removes an expiration on ``name``" + return self.execute_command('PERSIST', name) + + def pexpire(self, name, time): + """ + Set an expire flag on key ``name`` for ``time`` milliseconds. + ``time`` can be represented by an integer or a Python timedelta + object. + """ + if isinstance(time, datetime.timedelta): + time = int(time.total_seconds() * 1000) + return self.execute_command('PEXPIRE', name, time) + + def pexpireat(self, name, when): + """ + Set an expire flag on key ``name``. ``when`` can be represented + as an integer representing unix time in milliseconds (unix time * 1000) + or a Python datetime object. + """ + if isinstance(when, datetime.datetime): + ms = int(when.microsecond / 1000) + when = int(time.mktime(when.timetuple())) * 1000 + ms + return self.execute_command('PEXPIREAT', name, when) + + def psetex(self, name, time_ms, value): + """ + Set the value of key ``name`` to ``value`` that expires in ``time_ms`` + milliseconds. ``time_ms`` can be represented by an integer or a Python + timedelta object + """ + if isinstance(time_ms, datetime.timedelta): + time_ms = int(time_ms.total_seconds() * 1000) + return self.execute_command('PSETEX', name, time_ms, value) + + def pttl(self, name): + "Returns the number of milliseconds until the key ``name`` will expire" + return self.execute_command('PTTL', name) + + def hrandfield(self, key, count=None, withvalues=False): + """ + Return a random field from the hash value stored at key. + + count: if the argument is positive, return an array of distinct fields. + If called with a negative count, the behavior changes and the command + is allowed to return the same field multiple times. In this case, + the number of returned fields is the absolute value of the + specified count. + withvalues: The optional WITHVALUES modifier changes the reply so it + includes the respective values of the randomly selected hash fields. + """ + params = [] + if count is not None: + params.append(count) + if withvalues: + params.append("WITHVALUES") + + return self.execute_command("HRANDFIELD", key, *params) + + def randomkey(self): + "Returns the name of a random key" + return self.execute_command('RANDOMKEY') + + def rename(self, src, dst): + """ + Rename key ``src`` to ``dst`` + """ + return self.execute_command('RENAME', src, dst) + + def renamenx(self, src, dst): + "Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist" + return self.execute_command('RENAMENX', src, dst) + + def restore(self, name, ttl, value, replace=False, absttl=False): + """ + Create a key using the provided serialized value, previously obtained + using DUMP. + + ``replace`` allows an existing key on ``name`` to be overridden. If + it's not specified an error is raised on collision. + + ``absttl`` if True, specified ``ttl`` should represent an absolute Unix + timestamp in milliseconds in which the key will expire. (Redis 5.0 or + greater). + """ + params = [name, ttl, value] + if replace: + params.append('REPLACE') + if absttl: + params.append('ABSTTL') + return self.execute_command('RESTORE', *params) + + def set(self, name, value, + ex=None, px=None, nx=False, xx=False, keepttl=False, get=False): + """ + Set the value at key ``name`` to ``value`` + + ``ex`` sets an expire flag on key ``name`` for ``ex`` seconds. + + ``px`` sets an expire flag on key ``name`` for ``px`` milliseconds. + + ``nx`` if set to True, set the value at key ``name`` to ``value`` only + if it does not exist. + + ``xx`` if set to True, set the value at key ``name`` to ``value`` only + if it already exists. + + ``keepttl`` if True, retain the time to live associated with the key. + (Available since Redis 6.0) + + ``get`` if True, set the value at key ``name`` to ``value`` and return + the old value stored at key, or None when key did not exist. + (Available since Redis 6.2) + """ + pieces = [name, value] + options = {} + if ex is not None: + pieces.append('EX') + if isinstance(ex, datetime.timedelta): + ex = int(ex.total_seconds()) + pieces.append(ex) + if px is not None: + pieces.append('PX') + if isinstance(px, datetime.timedelta): + px = int(px.total_seconds() * 1000) + pieces.append(px) + + if nx: + pieces.append('NX') + if xx: + pieces.append('XX') + + if keepttl: + pieces.append('KEEPTTL') + + if get: + pieces.append('GET') + options["get"] = True + + return self.execute_command('SET', *pieces, **options) + + def __setitem__(self, name, value): + self.set(name, value) + + def setbit(self, name, offset, value): + """ + Flag the ``offset`` in ``name`` as ``value``. Returns a boolean + indicating the previous value of ``offset``. + """ + value = value and 1 or 0 + return self.execute_command('SETBIT', name, offset, value) + + def setex(self, name, time, value): + """ + Set the value of key ``name`` to ``value`` that expires in ``time`` + seconds. ``time`` can be represented by an integer or a Python + timedelta object. + """ + if isinstance(time, datetime.timedelta): + time = int(time.total_seconds()) + return self.execute_command('SETEX', name, time, value) + + def setnx(self, name, value): + "Set the value of key ``name`` to ``value`` if key doesn't exist" + return self.execute_command('SETNX', name, value) + + def setrange(self, name, offset, value): + """ + Overwrite bytes in the value of ``name`` starting at ``offset`` with + ``value``. If ``offset`` plus the length of ``value`` exceeds the + length of the original value, the new value will be larger than before. + If ``offset`` exceeds the length of the original value, null bytes + will be used to pad between the end of the previous value and the start + of what's being injected. + + Returns the length of the new string. + """ + return self.execute_command('SETRANGE', name, offset, value) + + def strlen(self, name): + "Return the number of bytes stored in the value of ``name``" + return self.execute_command('STRLEN', name) + + def substr(self, name, start, end=-1): + """ + Return a substring of the string at key ``name``. ``start`` and ``end`` + are 0-based integers specifying the portion of the string to return. + """ + return self.execute_command('SUBSTR', name, start, end) + + def touch(self, *args): + """ + Alters the last access time of a key(s) ``*args``. A key is ignored + if it does not exist. + """ + return self.execute_command('TOUCH', *args) + + def ttl(self, name): + "Returns the number of seconds until the key ``name`` will expire" + return self.execute_command('TTL', name) + + def type(self, name): + "Returns the type of key ``name``" + return self.execute_command('TYPE', name) + + def watch(self, *names): + """ + Watches the values at keys ``names``, or None if the key doesn't exist + """ + warnings.warn(DeprecationWarning('Call WATCH from a Pipeline object')) + + def unwatch(self): + """ + Unwatches the value at key ``name``, or None of the key doesn't exist + """ + warnings.warn( + DeprecationWarning('Call UNWATCH from a Pipeline object')) + + def unlink(self, *names): + "Unlink one or more keys specified by ``names``" + return self.execute_command('UNLINK', *names) + + # LIST COMMANDS + def blpop(self, keys, timeout=0): + """ + LPOP a value off of the first non-empty list + named in the ``keys`` list. + + If none of the lists in ``keys`` has a value to LPOP, then block + for ``timeout`` seconds, or until a value gets pushed on to one + of the lists. + + If timeout is 0, then block indefinitely. + """ + if timeout is None: + timeout = 0 + keys = list_or_args(keys, None) + keys.append(timeout) + return self.execute_command('BLPOP', *keys) + + def brpop(self, keys, timeout=0): + """ + RPOP a value off of the first non-empty list + named in the ``keys`` list. + + If none of the lists in ``keys`` has a value to RPOP, then block + for ``timeout`` seconds, or until a value gets pushed on to one + of the lists. + + If timeout is 0, then block indefinitely. + """ + if timeout is None: + timeout = 0 + keys = list_or_args(keys, None) + keys.append(timeout) + return self.execute_command('BRPOP', *keys) + + def brpoplpush(self, src, dst, timeout=0): + """ + Pop a value off the tail of ``src``, push it on the head of ``dst`` + and then return it. + + This command blocks until a value is in ``src`` or until ``timeout`` + seconds elapse, whichever is first. A ``timeout`` value of 0 blocks + forever. + """ + if timeout is None: + timeout = 0 + return self.execute_command('BRPOPLPUSH', src, dst, timeout) + + def lindex(self, name, index): + """ + Return the item from list ``name`` at position ``index`` + + Negative indexes are supported and will return an item at the + end of the list + """ + return self.execute_command('LINDEX', name, index) + + def linsert(self, name, where, refvalue, value): + """ + Insert ``value`` in list ``name`` either immediately before or after + [``where``] ``refvalue`` + + Returns the new length of the list on success or -1 if ``refvalue`` + is not in the list. + """ + return self.execute_command('LINSERT', name, where, refvalue, value) + + def llen(self, name): + "Return the length of the list ``name``" + return self.execute_command('LLEN', name) + + def lpop(self, name, count=None): + """ + Removes and returns the first elements of the list ``name``. + + By default, the command pops a single element from the beginning of + the list. When provided with the optional ``count`` argument, the reply + will consist of up to count elements, depending on the list's length. + """ + if count is not None: + return self.execute_command('LPOP', name, count) + else: + return self.execute_command('LPOP', name) + + def lpush(self, name, *values): + "Push ``values`` onto the head of the list ``name``" + return self.execute_command('LPUSH', name, *values) + + def lpushx(self, name, value): + "Push ``value`` onto the head of the list ``name`` if ``name`` exists" + return self.execute_command('LPUSHX', name, value) + + def lrange(self, name, start, end): + """ + Return a slice of the list ``name`` between + position ``start`` and ``end`` + + ``start`` and ``end`` can be negative numbers just like + Python slicing notation + """ + return self.execute_command('LRANGE', name, start, end) + + def lrem(self, name, count, value): + """ + Remove the first ``count`` occurrences of elements equal to ``value`` + from the list stored at ``name``. + + The count argument influences the operation in the following ways: + count > 0: Remove elements equal to value moving from head to tail. + count < 0: Remove elements equal to value moving from tail to head. + count = 0: Remove all elements equal to value. + """ + return self.execute_command('LREM', name, count, value) + + def lset(self, name, index, value): + "Set ``position`` of list ``name`` to ``value``" + return self.execute_command('LSET', name, index, value) + + def ltrim(self, name, start, end): + """ + Trim the list ``name``, removing all values not within the slice + between ``start`` and ``end`` + + ``start`` and ``end`` can be negative numbers just like + Python slicing notation + """ + return self.execute_command('LTRIM', name, start, end) + + def rpop(self, name, count=None): + """ + Removes and returns the last elements of the list ``name``. + + By default, the command pops a single element from the end of the list. + When provided with the optional ``count`` argument, the reply will + consist of up to count elements, depending on the list's length. + """ + if count is not None: + return self.execute_command('RPOP', name, count) + else: + return self.execute_command('RPOP', name) + + def rpoplpush(self, src, dst): + """ + RPOP a value off of the ``src`` list and atomically LPUSH it + on to the ``dst`` list. Returns the value. + """ + return self.execute_command('RPOPLPUSH', src, dst) + + def rpush(self, name, *values): + "Push ``values`` onto the tail of the list ``name``" + return self.execute_command('RPUSH', name, *values) + + def rpushx(self, name, value): + "Push ``value`` onto the tail of the list ``name`` if ``name`` exists" + return self.execute_command('RPUSHX', name, value) + + def lpos(self, name, value, rank=None, count=None, maxlen=None): + """ + Get position of ``value`` within the list ``name`` + + If specified, ``rank`` indicates the "rank" of the first element to + return in case there are multiple copies of ``value`` in the list. + By default, LPOS returns the position of the first occurrence of + ``value`` in the list. When ``rank`` 2, LPOS returns the position of + the second ``value`` in the list. If ``rank`` is negative, LPOS + searches the list in reverse. For example, -1 would return the + position of the last occurrence of ``value`` and -2 would return the + position of the next to last occurrence of ``value``. + + If specified, ``count`` indicates that LPOS should return a list of + up to ``count`` positions. A ``count`` of 2 would return a list of + up to 2 positions. A ``count`` of 0 returns a list of all positions + matching ``value``. When ``count`` is specified and but ``value`` + does not exist in the list, an empty list is returned. + + If specified, ``maxlen`` indicates the maximum number of list + elements to scan. A ``maxlen`` of 1000 will only return the + position(s) of items within the first 1000 entries in the list. + A ``maxlen`` of 0 (the default) will scan the entire list. + """ + pieces = [name, value] + if rank is not None: + pieces.extend(['RANK', rank]) + + if count is not None: + pieces.extend(['COUNT', count]) + + if maxlen is not None: + pieces.extend(['MAXLEN', maxlen]) + + return self.execute_command('LPOS', *pieces) + + def sort(self, name, start=None, num=None, by=None, get=None, + desc=False, alpha=False, store=None, groups=False): + """ + Sort and return the list, set or sorted set at ``name``. + + ``start`` and ``num`` allow for paging through the sorted data + + ``by`` allows using an external key to weight and sort the items. + Use an "*" to indicate where in the key the item value is located + + ``get`` allows for returning items from external keys rather than the + sorted data itself. Use an "*" to indicate where in the key + the item value is located + + ``desc`` allows for reversing the sort + + ``alpha`` allows for sorting lexicographically rather than numerically + + ``store`` allows for storing the result of the sort into + the key ``store`` + + ``groups`` if set to True and if ``get`` contains at least two + elements, sort will return a list of tuples, each containing the + values fetched from the arguments to ``get``. + + """ + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + + pieces = [name] + if by is not None: + pieces.append(b'BY') + pieces.append(by) + if start is not None and num is not None: + pieces.append(b'LIMIT') + pieces.append(start) + pieces.append(num) + if get is not None: + # If get is a string assume we want to get a single value. + # Otherwise assume it's an interable and we want to get multiple + # values. We can't just iterate blindly because strings are + # iterable. + if isinstance(get, (bytes, str)): + pieces.append(b'GET') + pieces.append(get) + else: + for g in get: + pieces.append(b'GET') + pieces.append(g) + if desc: + pieces.append(b'DESC') + if alpha: + pieces.append(b'ALPHA') + if store is not None: + pieces.append(b'STORE') + pieces.append(store) + + if groups: + if not get or isinstance(get, (bytes, str)) or len(get) < 2: + raise DataError('when using "groups" the "get" argument ' + 'must be specified and contain at least ' + 'two keys') + + options = {'groups': len(get) if groups else None} + return self.execute_command('SORT', *pieces, **options) + + # SCAN COMMANDS + def scan(self, cursor=0, match=None, count=None, _type=None): + """ + Incrementally return lists of key names. Also return a cursor + indicating the scan position. + + ``match`` allows for filtering the keys by pattern + + ``count`` provides a hint to Redis about the number of keys to + return per batch. + + ``_type`` filters the returned values by a particular Redis type. + Stock Redis instances allow for the following types: + HASH, LIST, SET, STREAM, STRING, ZSET + Additionally, Redis modules can expose other types as well. + """ + pieces = [cursor] + if match is not None: + pieces.extend([b'MATCH', match]) + if count is not None: + pieces.extend([b'COUNT', count]) + if _type is not None: + pieces.extend([b'TYPE', _type]) + return self.execute_command('SCAN', *pieces) + + def scan_iter(self, match=None, count=None, _type=None): + """ + Make an iterator using the SCAN command so that the client doesn't + need to remember the cursor position. + + ``match`` allows for filtering the keys by pattern + + ``count`` provides a hint to Redis about the number of keys to + return per batch. + + ``_type`` filters the returned values by a particular Redis type. + Stock Redis instances allow for the following types: + HASH, LIST, SET, STREAM, STRING, ZSET + Additionally, Redis modules can expose other types as well. + """ + cursor = '0' + while cursor != 0: + cursor, data = self.scan(cursor=cursor, match=match, + count=count, _type=_type) + yield from data + + def sscan(self, name, cursor=0, match=None, count=None): + """ + Incrementally return lists of elements in a set. Also return a cursor + indicating the scan position. + + ``match`` allows for filtering the keys by pattern + + ``count`` allows for hint the minimum number of returns + """ + pieces = [name, cursor] + if match is not None: + pieces.extend([b'MATCH', match]) + if count is not None: + pieces.extend([b'COUNT', count]) + return self.execute_command('SSCAN', *pieces) + + def sscan_iter(self, name, match=None, count=None): + """ + Make an iterator using the SSCAN command so that the client doesn't + need to remember the cursor position. + + ``match`` allows for filtering the keys by pattern + + ``count`` allows for hint the minimum number of returns + """ + cursor = '0' + while cursor != 0: + cursor, data = self.sscan(name, cursor=cursor, + match=match, count=count) + yield from data + + def hscan(self, name, cursor=0, match=None, count=None): + """ + Incrementally return key/value slices in a hash. Also return a cursor + indicating the scan position. + + ``match`` allows for filtering the keys by pattern + + ``count`` allows for hint the minimum number of returns + """ + pieces = [name, cursor] + if match is not None: + pieces.extend([b'MATCH', match]) + if count is not None: + pieces.extend([b'COUNT', count]) + return self.execute_command('HSCAN', *pieces) + + def hscan_iter(self, name, match=None, count=None): + """ + Make an iterator using the HSCAN command so that the client doesn't + need to remember the cursor position. + + ``match`` allows for filtering the keys by pattern + + ``count`` allows for hint the minimum number of returns + """ + cursor = '0' + while cursor != 0: + cursor, data = self.hscan(name, cursor=cursor, + match=match, count=count) + yield from data.items() + + def zscan(self, name, cursor=0, match=None, count=None, + score_cast_func=float): + """ + Incrementally return lists of elements in a sorted set. Also return a + cursor indicating the scan position. + + ``match`` allows for filtering the keys by pattern + + ``count`` allows for hint the minimum number of returns + + ``score_cast_func`` a callable used to cast the score return value + """ + pieces = [name, cursor] + if match is not None: + pieces.extend([b'MATCH', match]) + if count is not None: + pieces.extend([b'COUNT', count]) + options = {'score_cast_func': score_cast_func} + return self.execute_command('ZSCAN', *pieces, **options) + + def zscan_iter(self, name, match=None, count=None, + score_cast_func=float): + """ + Make an iterator using the ZSCAN command so that the client doesn't + need to remember the cursor position. + + ``match`` allows for filtering the keys by pattern + + ``count`` allows for hint the minimum number of returns + + ``score_cast_func`` a callable used to cast the score return value + """ + cursor = '0' + while cursor != 0: + cursor, data = self.zscan(name, cursor=cursor, match=match, + count=count, + score_cast_func=score_cast_func) + yield from data + + # SET COMMANDS + def sadd(self, name, *values): + "Add ``value(s)`` to set ``name``" + return self.execute_command('SADD', name, *values) + + def scard(self, name): + "Return the number of elements in set ``name``" + return self.execute_command('SCARD', name) + + def sdiff(self, keys, *args): + "Return the difference of sets specified by ``keys``" + args = list_or_args(keys, args) + return self.execute_command('SDIFF', *args) + + def sdiffstore(self, dest, keys, *args): + """ + Store the difference of sets specified by ``keys`` into a new + set named ``dest``. Returns the number of keys in the new set. + """ + args = list_or_args(keys, args) + return self.execute_command('SDIFFSTORE', dest, *args) + + def sinter(self, keys, *args): + "Return the intersection of sets specified by ``keys``" + args = list_or_args(keys, args) + return self.execute_command('SINTER', *args) + + def sinterstore(self, dest, keys, *args): + """ + Store the intersection of sets specified by ``keys`` into a new + set named ``dest``. Returns the number of keys in the new set. + """ + args = list_or_args(keys, args) + return self.execute_command('SINTERSTORE', dest, *args) + + def sismember(self, name, value): + "Return a boolean indicating if ``value`` is a member of set ``name``" + return self.execute_command('SISMEMBER', name, value) + + def smembers(self, name): + "Return all members of the set ``name``" + return self.execute_command('SMEMBERS', name) + + def smove(self, src, dst, value): + "Move ``value`` from set ``src`` to set ``dst`` atomically" + return self.execute_command('SMOVE', src, dst, value) + + def spop(self, name, count=None): + "Remove and return a random member of set ``name``" + args = (count is not None) and [count] or [] + return self.execute_command('SPOP', name, *args) + + def srandmember(self, name, number=None): + """ + If ``number`` is None, returns a random member of set ``name``. + + If ``number`` is supplied, returns a list of ``number`` random + members of set ``name``. Note this is only available when running + Redis 2.6+. + """ + args = (number is not None) and [number] or [] + return self.execute_command('SRANDMEMBER', name, *args) + + def srem(self, name, *values): + "Remove ``values`` from set ``name``" + return self.execute_command('SREM', name, *values) + + def sunion(self, keys, *args): + "Return the union of sets specified by ``keys``" + args = list_or_args(keys, args) + return self.execute_command('SUNION', *args) + + def sunionstore(self, dest, keys, *args): + """ + Store the union of sets specified by ``keys`` into a new + set named ``dest``. Returns the number of keys in the new set. + """ + args = list_or_args(keys, args) + return self.execute_command('SUNIONSTORE', dest, *args) + + # STREAMS COMMANDS + def xack(self, name, groupname, *ids): + """ + Acknowledges the successful processing of one or more messages. + name: name of the stream. + groupname: name of the consumer group. + *ids: message ids to acknowledge. + """ + return self.execute_command('XACK', name, groupname, *ids) + + def xadd(self, name, fields, id='*', maxlen=None, approximate=True, + nomkstream=False): + """ + Add to a stream. + name: name of the stream + fields: dict of field/value pairs to insert into the stream + id: Location to insert this record. By default it is appended. + maxlen: truncate old stream members beyond this size + approximate: actual stream length may be slightly more than maxlen + nomkstream: When set to true, do not make a stream + """ + pieces = [] + if maxlen is not None: + if not isinstance(maxlen, int) or maxlen < 1: + raise DataError('XADD maxlen must be a positive integer') + pieces.append(b'MAXLEN') + if approximate: + pieces.append(b'~') + pieces.append(str(maxlen)) + if nomkstream: + pieces.append(b'NOMKSTREAM') + pieces.append(id) + if not isinstance(fields, dict) or len(fields) == 0: + raise DataError('XADD fields must be a non-empty dict') + for pair in fields.items(): + pieces.extend(pair) + return self.execute_command('XADD', name, *pieces) + + def xautoclaim(self, name, groupname, consumername, min_idle_time, + start_id=0, count=None, justid=False): + """ + Transfers ownership of pending stream entries that match the specified + criteria. Conceptually, equivalent to calling XPENDING and then XCLAIM, + but provides a more straightforward way to deal with message delivery + failures via SCAN-like semantics. + name: name of the stream. + groupname: name of the consumer group. + consumername: name of a consumer that claims the message. + min_idle_time: filter messages that were idle less than this amount of + milliseconds. + start_id: filter messages with equal or greater ID. + count: optional integer, upper limit of the number of entries that the + command attempts to claim. Set to 100 by default. + justid: optional boolean, false by default. Return just an array of IDs + of messages successfully claimed, without returning the actual message + """ + try: + if int(min_idle_time) < 0: + raise DataError("XAUTOCLAIM min_idle_time must be a non" + "negative integer") + except TypeError: + pass + + kwargs = {} + pieces = [name, groupname, consumername, min_idle_time, start_id] + + try: + if int(count) < 0: + raise DataError("XPENDING count must be a integer >= 0") + pieces.extend([b'COUNT', count]) + except TypeError: + pass + if justid: + pieces.append(b'JUSTID') + kwargs['parse_justid'] = True + + return self.execute_command('XAUTOCLAIM', *pieces, **kwargs) + + def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, + idle=None, time=None, retrycount=None, force=False, + justid=False): + """ + Changes the ownership of a pending message. + name: name of the stream. + groupname: name of the consumer group. + consumername: name of a consumer that claims the message. + min_idle_time: filter messages that were idle less than this amount of + milliseconds + message_ids: non-empty list or tuple of message IDs to claim + idle: optional. Set the idle time (last time it was delivered) of the + message in ms + time: optional integer. This is the same as idle but instead of a + relative amount of milliseconds, it sets the idle time to a specific + Unix time (in milliseconds). + retrycount: optional integer. set the retry counter to the specified + value. This counter is incremented every time a message is delivered + again. + force: optional boolean, false by default. Creates the pending message + entry in the PEL even if certain specified IDs are not already in the + PEL assigned to a different client. + justid: optional boolean, false by default. Return just an array of IDs + of messages successfully claimed, without returning the actual message + """ + if not isinstance(min_idle_time, int) or min_idle_time < 0: + raise DataError("XCLAIM min_idle_time must be a non negative " + "integer") + if not isinstance(message_ids, (list, tuple)) or not message_ids: + raise DataError("XCLAIM message_ids must be a non empty list or " + "tuple of message IDs to claim") + + kwargs = {} + pieces = [name, groupname, consumername, str(min_idle_time)] + pieces.extend(list(message_ids)) + + if idle is not None: + if not isinstance(idle, int): + raise DataError("XCLAIM idle must be an integer") + pieces.extend((b'IDLE', str(idle))) + if time is not None: + if not isinstance(time, int): + raise DataError("XCLAIM time must be an integer") + pieces.extend((b'TIME', str(time))) + if retrycount is not None: + if not isinstance(retrycount, int): + raise DataError("XCLAIM retrycount must be an integer") + pieces.extend((b'RETRYCOUNT', str(retrycount))) + + if force: + if not isinstance(force, bool): + raise DataError("XCLAIM force must be a boolean") + pieces.append(b'FORCE') + if justid: + if not isinstance(justid, bool): + raise DataError("XCLAIM justid must be a boolean") + pieces.append(b'JUSTID') + kwargs['parse_justid'] = True + return self.execute_command('XCLAIM', *pieces, **kwargs) + + def xdel(self, name, *ids): + """ + Deletes one or more messages from a stream. + name: name of the stream. + *ids: message ids to delete. + """ + return self.execute_command('XDEL', name, *ids) + + def xgroup_create(self, name, groupname, id='$', mkstream=False): + """ + Create a new consumer group associated with a stream. + name: name of the stream. + groupname: name of the consumer group. + id: ID of the last item in the stream to consider already delivered. + """ + pieces = ['XGROUP CREATE', name, groupname, id] + if mkstream: + pieces.append(b'MKSTREAM') + return self.execute_command(*pieces) + + def xgroup_delconsumer(self, name, groupname, consumername): + """ + Remove a specific consumer from a consumer group. + Returns the number of pending messages that the consumer had before it + was deleted. + name: name of the stream. + groupname: name of the consumer group. + consumername: name of consumer to delete + """ + return self.execute_command('XGROUP DELCONSUMER', name, groupname, + consumername) + + def xgroup_destroy(self, name, groupname): + """ + Destroy a consumer group. + name: name of the stream. + groupname: name of the consumer group. + """ + return self.execute_command('XGROUP DESTROY', name, groupname) + + def xgroup_setid(self, name, groupname, id): + """ + Set the consumer group last delivered ID to something else. + name: name of the stream. + groupname: name of the consumer group. + id: ID of the last item in the stream to consider already delivered. + """ + return self.execute_command('XGROUP SETID', name, groupname, id) + + def xinfo_consumers(self, name, groupname): + """ + Returns general information about the consumers in the group. + name: name of the stream. + groupname: name of the consumer group. + """ + return self.execute_command('XINFO CONSUMERS', name, groupname) + + def xinfo_groups(self, name): + """ + Returns general information about the consumer groups of the stream. + name: name of the stream. + """ + return self.execute_command('XINFO GROUPS', name) + + def xinfo_stream(self, name): + """ + Returns general information about the stream. + name: name of the stream. + """ + return self.execute_command('XINFO STREAM', name) + + def xlen(self, name): + """ + Returns the number of elements in a given stream. + """ + return self.execute_command('XLEN', name) + + def xpending(self, name, groupname): + """ + Returns information about pending messages of a group. + name: name of the stream. + groupname: name of the consumer group. + """ + return self.execute_command('XPENDING', name, groupname) + + def xpending_range(self, name, groupname, min, max, count, + consumername=None, idle=None): + """ + Returns information about pending messages, in a range. + name: name of the stream. + groupname: name of the consumer group. + min: minimum stream ID. + max: maximum stream ID. + count: number of messages to return + consumername: name of a consumer to filter by (optional). + idle: available from version 6.2. filter entries by their + idle-time, given in milliseconds (optional). + """ + if {min, max, count} == {None}: + if idle is not None or consumername is not None: + raise DataError("if XPENDING is provided with idle time" + " or consumername, it must be provided" + " with min, max and count parameters") + return self.xpending(name, groupname) + + pieces = [name, groupname] + if min is None or max is None or count is None: + raise DataError("XPENDING must be provided with min, max " + "and count parameters, or none of them.") + # idle + try: + if int(idle) < 0: + raise DataError("XPENDING idle must be a integer >= 0") + pieces.extend(['IDLE', idle]) + except TypeError: + pass + # count + try: + if int(count) < 0: + raise DataError("XPENDING count must be a integer >= 0") + pieces.extend([min, max, count]) + except TypeError: + pass + + return self.execute_command('XPENDING', *pieces, parse_detail=True) + + def xrange(self, name, min='-', max='+', count=None): + """ + Read stream values within an interval. + name: name of the stream. + start: first stream ID. defaults to '-', + meaning the earliest available. + finish: last stream ID. defaults to '+', + meaning the latest available. + count: if set, only return this many items, beginning with the + earliest available. + """ + pieces = [min, max] + if count is not None: + if not isinstance(count, int) or count < 1: + raise DataError('XRANGE count must be a positive integer') + pieces.append(b'COUNT') + pieces.append(str(count)) + + return self.execute_command('XRANGE', name, *pieces) + + def xread(self, streams, count=None, block=None): + """ + Block and monitor multiple streams for new data. + streams: a dict of stream names to stream IDs, where + IDs indicate the last ID already seen. + count: if set, only return this many items, beginning with the + earliest available. + block: number of milliseconds to wait, if nothing already present. + """ + pieces = [] + if block is not None: + if not isinstance(block, int) or block < 0: + raise DataError('XREAD block must be a non-negative integer') + pieces.append(b'BLOCK') + pieces.append(str(block)) + if count is not None: + if not isinstance(count, int) or count < 1: + raise DataError('XREAD count must be a positive integer') + pieces.append(b'COUNT') + pieces.append(str(count)) + if not isinstance(streams, dict) or len(streams) == 0: + raise DataError('XREAD streams must be a non empty dict') + pieces.append(b'STREAMS') + keys, values = zip(*streams.items()) + pieces.extend(keys) + pieces.extend(values) + return self.execute_command('XREAD', *pieces) + + def xreadgroup(self, groupname, consumername, streams, count=None, + block=None, noack=False): + """ + Read from a stream via a consumer group. + groupname: name of the consumer group. + consumername: name of the requesting consumer. + streams: a dict of stream names to stream IDs, where + IDs indicate the last ID already seen. + count: if set, only return this many items, beginning with the + earliest available. + block: number of milliseconds to wait, if nothing already present. + noack: do not add messages to the PEL + """ + pieces = [b'GROUP', groupname, consumername] + if count is not None: + if not isinstance(count, int) or count < 1: + raise DataError("XREADGROUP count must be a positive integer") + pieces.append(b'COUNT') + pieces.append(str(count)) + if block is not None: + if not isinstance(block, int) or block < 0: + raise DataError("XREADGROUP block must be a non-negative " + "integer") + pieces.append(b'BLOCK') + pieces.append(str(block)) + if noack: + pieces.append(b'NOACK') + if not isinstance(streams, dict) or len(streams) == 0: + raise DataError('XREADGROUP streams must be a non empty dict') + pieces.append(b'STREAMS') + pieces.extend(streams.keys()) + pieces.extend(streams.values()) + return self.execute_command('XREADGROUP', *pieces) + + def xrevrange(self, name, max='+', min='-', count=None): + """ + Read stream values within an interval, in reverse order. + name: name of the stream + start: first stream ID. defaults to '+', + meaning the latest available. + finish: last stream ID. defaults to '-', + meaning the earliest available. + count: if set, only return this many items, beginning with the + latest available. + """ + pieces = [max, min] + if count is not None: + if not isinstance(count, int) or count < 1: + raise DataError('XREVRANGE count must be a positive integer') + pieces.append(b'COUNT') + pieces.append(str(count)) + + return self.execute_command('XREVRANGE', name, *pieces) + + def xtrim(self, name, maxlen=None, approximate=True, minid=None, + limit=None): + """ + Trims old messages from a stream. + name: name of the stream. + maxlen: truncate old stream messages beyond this size + approximate: actual stream length may be slightly more than maxlen + minin: the minimum id in the stream to query + limit: specifies the maximum number of entries to retrieve + """ + pieces = [] + if maxlen is not None and minid is not None: + raise DataError("Only one of ```maxlen``` or ```minid```", + "may be specified") + + if maxlen is not None: + pieces.append(b'MAXLEN') + if minid is not None: + pieces.append(b'MINID') + if approximate: + pieces.append(b'~') + if maxlen is not None: + pieces.append(maxlen) + if minid is not None: + pieces.append(minid) + if limit is not None: + pieces.append(b"LIMIT") + pieces.append(limit) + + return self.execute_command('XTRIM', name, *pieces) + + # SORTED SET COMMANDS + def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, + gt=None, lt=None): + """ + Set any number of element-name, score pairs to the key ``name``. Pairs + are specified as a dict of element-names keys to score values. + + ``nx`` forces ZADD to only create new elements and not to update + scores for elements that already exist. + + ``xx`` forces ZADD to only update scores of elements that already + exist. New elements will not be added. + + ``ch`` modifies the return value to be the numbers of elements changed. + Changed elements include new elements that were added and elements + whose scores changed. + + ``incr`` modifies ZADD to behave like ZINCRBY. In this mode only a + single element/score pair can be specified and the score is the amount + the existing score will be incremented by. When using this mode the + return value of ZADD will be the new score of the element. + + ``LT`` Only update existing elements if the new score is less than + the current score. This flag doesn't prevent adding new elements. + + ``GT`` Only update existing elements if the new score is greater than + the current score. This flag doesn't prevent adding new elements. + + The return value of ZADD varies based on the mode specified. With no + options, ZADD returns the number of new elements added to the sorted + set. + + ``NX``, ``LT``, and ``GT`` are mutually exclusive options. + See: https://redis.io/commands/ZADD + """ + if not mapping: + raise DataError("ZADD requires at least one element/score pair") + if nx and xx: + raise DataError("ZADD allows either 'nx' or 'xx', not both") + if incr and len(mapping) != 1: + raise DataError("ZADD option 'incr' only works when passing a " + "single element/score pair") + if nx is True and (gt is not None or lt is not None): + raise DataError("Only one of 'nx', 'lt', or 'gr' may be defined.") + + pieces = [] + options = {} + if nx: + pieces.append(b'NX') + if xx: + pieces.append(b'XX') + if ch: + pieces.append(b'CH') + if incr: + pieces.append(b'INCR') + options['as_score'] = True + if gt: + pieces.append(b'GT') + if lt: + pieces.append(b'LT') + for pair in mapping.items(): + pieces.append(pair[1]) + pieces.append(pair[0]) + return self.execute_command('ZADD', name, *pieces, **options) + + def zcard(self, name): + "Return the number of elements in the sorted set ``name``" + return self.execute_command('ZCARD', name) + + def zcount(self, name, min, max): + """ + Returns the number of elements in the sorted set at key ``name`` with + a score between ``min`` and ``max``. + """ + return self.execute_command('ZCOUNT', name, min, max) + + def zdiff(self, keys, withscores=False): + """ + Returns the difference between the first and all successive input + sorted sets provided in ``keys``. + """ + pieces = [len(keys), *keys] + if withscores: + pieces.append("WITHSCORES") + return self.execute_command("ZDIFF", *pieces) + + def zdiffstore(self, dest, keys): + """ + Computes the difference between the first and all successive input + sorted sets provided in ``keys`` and stores the result in ``dest``. + """ + pieces = [len(keys), *keys] + return self.execute_command("ZDIFFSTORE", dest, *pieces) + + def zincrby(self, name, amount, value): + "Increment the score of ``value`` in sorted set ``name`` by ``amount``" + return self.execute_command('ZINCRBY', name, amount, value) + + def zinter(self, keys, aggregate=None, withscores=False): + """ + Return the intersect of multiple sorted sets specified by ``keys``. + With the ``aggregate`` option, it is possible to specify how the + results of the union are aggregated. This option defaults to SUM, + where the score of an element is summed across the inputs where it + exists. When this option is set to either MIN or MAX, the resulting + set will contain the minimum or maximum score of an element across + the inputs where it exists. + """ + return self._zaggregate('ZINTER', None, keys, aggregate, + withscores=withscores) + + def zinterstore(self, dest, keys, aggregate=None): + """ + Intersect multiple sorted sets specified by ``keys`` into a new + sorted set, ``dest``. Scores in the destination will be aggregated + based on the ``aggregate``. This option defaults to SUM, where the + score of an element is summed across the inputs where it exists. + When this option is set to either MIN or MAX, the resulting set will + contain the minimum or maximum score of an element across the inputs + where it exists. + """ + return self._zaggregate('ZINTERSTORE', dest, keys, aggregate) + + def zlexcount(self, name, min, max): + """ + Return the number of items in the sorted set ``name`` between the + lexicographical range ``min`` and ``max``. + """ + return self.execute_command('ZLEXCOUNT', name, min, max) + + def zpopmax(self, name, count=None): + """ + Remove and return up to ``count`` members with the highest scores + from the sorted set ``name``. + """ + args = (count is not None) and [count] or [] + options = { + 'withscores': True + } + return self.execute_command('ZPOPMAX', name, *args, **options) + + def zpopmin(self, name, count=None): + """ + Remove and return up to ``count`` members with the lowest scores + from the sorted set ``name``. + """ + args = (count is not None) and [count] or [] + options = { + 'withscores': True + } + return self.execute_command('ZPOPMIN', name, *args, **options) + + def zrandmember(self, key, count=None, withscores=False): + """ + Return a random element from the sorted set value stored at key. + + ``count`` if the argument is positive, return an array of distinct + fields. If called with a negative count, the behavior changes and + the command is allowed to return the same field multiple times. + In this case, the number of returned fields is the absolute value + of the specified count. + + ``withscores`` The optional WITHSCORES modifier changes the reply so it + includes the respective scores of the randomly selected elements from + the sorted set. + """ + params = [] + if count is not None: + params.append(count) + if withscores: + params.append("WITHSCORES") + + return self.execute_command("ZRANDMEMBER", key, *params) + + def bzpopmax(self, keys, timeout=0): + """ + ZPOPMAX a value off of the first non-empty sorted set + named in the ``keys`` list. + + If none of the sorted sets in ``keys`` has a value to ZPOPMAX, + then block for ``timeout`` seconds, or until a member gets added + to one of the sorted sets. + + If timeout is 0, then block indefinitely. + """ + if timeout is None: + timeout = 0 + keys = list_or_args(keys, None) + keys.append(timeout) + return self.execute_command('BZPOPMAX', *keys) + + def bzpopmin(self, keys, timeout=0): + """ + ZPOPMIN a value off of the first non-empty sorted set + named in the ``keys`` list. + + If none of the sorted sets in ``keys`` has a value to ZPOPMIN, + then block for ``timeout`` seconds, or until a member gets added + to one of the sorted sets. + + If timeout is 0, then block indefinitely. + """ + if timeout is None: + timeout = 0 + keys = list_or_args(keys, None) + keys.append(timeout) + return self.execute_command('BZPOPMIN', *keys) + + def zrange(self, name, start, end, desc=False, withscores=False, + score_cast_func=float): + """ + Return a range of values from sorted set ``name`` between + ``start`` and ``end`` sorted in ascending order. + + ``start`` and ``end`` can be negative, indicating the end of the range. + + ``desc`` a boolean indicating whether to sort the results descendingly + + ``withscores`` indicates to return the scores along with the values. + The return type is a list of (value, score) pairs + + ``score_cast_func`` a callable used to cast the score return value + """ + if desc: + return self.zrevrange(name, start, end, withscores, + score_cast_func) + pieces = ['ZRANGE', name, start, end] + if withscores: + pieces.append(b'WITHSCORES') + options = { + 'withscores': withscores, + 'score_cast_func': score_cast_func + } + return self.execute_command(*pieces, **options) + + def zrangestore(self, dest, name, start, end): + """ + Stores in ``dest`` the result of a range of values from sorted set + ``name`` between ``start`` and ``end`` sorted in ascending order. + + ``start`` and ``end`` can be negative, indicating the end of the range. + """ + return self.execute_command('ZRANGESTORE', dest, name, start, end) + + def zrangebylex(self, name, min, max, start=None, num=None): + """ + Return the lexicographical range of values from sorted set ``name`` + between ``min`` and ``max``. + + If ``start`` and ``num`` are specified, then return a slice of the + range. + """ + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + pieces = ['ZRANGEBYLEX', name, min, max] + if start is not None and num is not None: + pieces.extend([b'LIMIT', start, num]) + return self.execute_command(*pieces) + + def zrevrangebylex(self, name, max, min, start=None, num=None): + """ + Return the reversed lexicographical range of values from sorted set + ``name`` between ``max`` and ``min``. + + If ``start`` and ``num`` are specified, then return a slice of the + range. + """ + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + pieces = ['ZREVRANGEBYLEX', name, max, min] + if start is not None and num is not None: + pieces.extend([b'LIMIT', start, num]) + return self.execute_command(*pieces) + + def zrangebyscore(self, name, min, max, start=None, num=None, + withscores=False, score_cast_func=float): + """ + Return a range of values from the sorted set ``name`` with scores + between ``min`` and ``max``. + + If ``start`` and ``num`` are specified, then return a slice + of the range. + + ``withscores`` indicates to return the scores along with the values. + The return type is a list of (value, score) pairs + + `score_cast_func`` a callable used to cast the score return value + """ + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + pieces = ['ZRANGEBYSCORE', name, min, max] + if start is not None and num is not None: + pieces.extend([b'LIMIT', start, num]) + if withscores: + pieces.append(b'WITHSCORES') + options = { + 'withscores': withscores, + 'score_cast_func': score_cast_func + } + return self.execute_command(*pieces, **options) + + def zrank(self, name, value): + """ + Returns a 0-based value indicating the rank of ``value`` in sorted set + ``name`` + """ + return self.execute_command('ZRANK', name, value) + + def zrem(self, name, *values): + "Remove member ``values`` from sorted set ``name``" + return self.execute_command('ZREM', name, *values) + + def zremrangebylex(self, name, min, max): + """ + Remove all elements in the sorted set ``name`` between the + lexicographical range specified by ``min`` and ``max``. + + Returns the number of elements removed. + """ + return self.execute_command('ZREMRANGEBYLEX', name, min, max) + + def zremrangebyrank(self, name, min, max): + """ + Remove all elements in the sorted set ``name`` with ranks between + ``min`` and ``max``. Values are 0-based, ordered from smallest score + to largest. Values can be negative indicating the highest scores. + Returns the number of elements removed + """ + return self.execute_command('ZREMRANGEBYRANK', name, min, max) + + def zremrangebyscore(self, name, min, max): + """ + Remove all elements in the sorted set ``name`` with scores + between ``min`` and ``max``. Returns the number of elements removed. + """ + return self.execute_command('ZREMRANGEBYSCORE', name, min, max) + + def zrevrange(self, name, start, end, withscores=False, + score_cast_func=float): + """ + Return a range of values from sorted set ``name`` between + ``start`` and ``end`` sorted in descending order. + + ``start`` and ``end`` can be negative, indicating the end of the range. + + ``withscores`` indicates to return the scores along with the values + The return type is a list of (value, score) pairs + + ``score_cast_func`` a callable used to cast the score return value + """ + pieces = ['ZREVRANGE', name, start, end] + if withscores: + pieces.append(b'WITHSCORES') + options = { + 'withscores': withscores, + 'score_cast_func': score_cast_func + } + return self.execute_command(*pieces, **options) + + def zrevrangebyscore(self, name, max, min, start=None, num=None, + withscores=False, score_cast_func=float): + """ + Return a range of values from the sorted set ``name`` with scores + between ``min`` and ``max`` in descending order. + + If ``start`` and ``num`` are specified, then return a slice + of the range. + + ``withscores`` indicates to return the scores along with the values. + The return type is a list of (value, score) pairs + + ``score_cast_func`` a callable used to cast the score return value + """ + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + pieces = ['ZREVRANGEBYSCORE', name, max, min] + if start is not None and num is not None: + pieces.extend([b'LIMIT', start, num]) + if withscores: + pieces.append(b'WITHSCORES') + options = { + 'withscores': withscores, + 'score_cast_func': score_cast_func + } + return self.execute_command(*pieces, **options) + + def zrevrank(self, name, value): + """ + Returns a 0-based value indicating the descending rank of + ``value`` in sorted set ``name`` + """ + return self.execute_command('ZREVRANK', name, value) + + def zscore(self, name, value): + "Return the score of element ``value`` in sorted set ``name``" + return self.execute_command('ZSCORE', name, value) + + def zunion(self, keys, aggregate=None, withscores=False): + """ + Return the union of multiple sorted sets specified by ``keys``. + ``keys`` can be provided as dictionary of keys and their weights. + Scores will be aggregated based on the ``aggregate``, or SUM if + none is provided. + """ + return self._zaggregate('ZUNION', None, keys, aggregate, + withscores=withscores) + + def zunionstore(self, dest, keys, aggregate=None): + """ + Union multiple sorted sets specified by ``keys`` into + a new sorted set, ``dest``. Scores in the destination will be + aggregated based on the ``aggregate``, or SUM if none is provided. + """ + return self._zaggregate('ZUNIONSTORE', dest, keys, aggregate) + + def _zaggregate(self, command, dest, keys, aggregate=None, + **options): + pieces = [command] + if dest is not None: + pieces.append(dest) + pieces.append(len(keys)) + if isinstance(keys, dict): + keys, weights = keys.keys(), keys.values() + else: + weights = None + pieces.extend(keys) + if weights: + pieces.append(b'WEIGHTS') + pieces.extend(weights) + if aggregate: + if aggregate.upper() in ['SUM', 'MIN', 'MAX']: + pieces.append(b'AGGREGATE') + pieces.append(aggregate) + else: + raise DataError("aggregate can be sum, min or max.") + if options.get('withscores', False): + pieces.append(b'WITHSCORES') + return self.execute_command(*pieces, **options) + + # HYPERLOGLOG COMMANDS + def pfadd(self, name, *values): + "Adds the specified elements to the specified HyperLogLog." + return self.execute_command('PFADD', name, *values) + + def pfcount(self, *sources): + """ + Return the approximated cardinality of + the set observed by the HyperLogLog at key(s). + """ + return self.execute_command('PFCOUNT', *sources) + + def pfmerge(self, dest, *sources): + "Merge N different HyperLogLogs into a single one." + return self.execute_command('PFMERGE', dest, *sources) + + # HASH COMMANDS + def hdel(self, name, *keys): + "Delete ``keys`` from hash ``name``" + return self.execute_command('HDEL', name, *keys) + + def hexists(self, name, key): + "Returns a boolean indicating if ``key`` exists within hash ``name``" + return self.execute_command('HEXISTS', name, key) + + def hget(self, name, key): + "Return the value of ``key`` within the hash ``name``" + return self.execute_command('HGET', name, key) + + def hgetall(self, name): + "Return a Python dict of the hash's name/value pairs" + return self.execute_command('HGETALL', name) + + def hincrby(self, name, key, amount=1): + "Increment the value of ``key`` in hash ``name`` by ``amount``" + return self.execute_command('HINCRBY', name, key, amount) + + def hincrbyfloat(self, name, key, amount=1.0): + """ + Increment the value of ``key`` in hash ``name`` by floating ``amount`` + """ + return self.execute_command('HINCRBYFLOAT', name, key, amount) + + def hkeys(self, name): + "Return the list of keys within hash ``name``" + return self.execute_command('HKEYS', name) + + def hlen(self, name): + "Return the number of elements in hash ``name``" + return self.execute_command('HLEN', name) + + def hset(self, name, key=None, value=None, mapping=None): + """ + Set ``key`` to ``value`` within hash ``name``, + ``mapping`` accepts a dict of key/value pairs that will be + added to hash ``name``. + Returns the number of fields that were added. + """ + if key is None and not mapping: + raise DataError("'hset' with no key value pairs") + items = [] + if key is not None: + items.extend((key, value)) + if mapping: + for pair in mapping.items(): + items.extend(pair) + + return self.execute_command('HSET', name, *items) + + def hsetnx(self, name, key, value): + """ + Set ``key`` to ``value`` within hash ``name`` if ``key`` does not + exist. Returns 1 if HSETNX created a field, otherwise 0. + """ + return self.execute_command('HSETNX', name, key, value) + + def hmset(self, name, mapping): + """ + Set key to value within hash ``name`` for each corresponding + key and value from the ``mapping`` dict. + """ + warnings.warn( + '%s.hmset() is deprecated. Use %s.hset() instead.' + % (self.__class__.__name__, self.__class__.__name__), + DeprecationWarning, + stacklevel=2, + ) + if not mapping: + raise DataError("'hmset' with 'mapping' of length 0") + items = [] + for pair in mapping.items(): + items.extend(pair) + return self.execute_command('HMSET', name, *items) + + def hmget(self, name, keys, *args): + "Returns a list of values ordered identically to ``keys``" + args = list_or_args(keys, args) + return self.execute_command('HMGET', name, *args) + + def hvals(self, name): + "Return the list of values within hash ``name``" + return self.execute_command('HVALS', name) + + def hstrlen(self, name, key): + """ + Return the number of bytes stored in the value of ``key`` + within hash ``name`` + """ + return self.execute_command('HSTRLEN', name, key) + + def publish(self, channel, message): + """ + Publish ``message`` on ``channel``. + Returns the number of subscribers the message was delivered to. + """ + return self.execute_command('PUBLISH', channel, message) + + def pubsub_channels(self, pattern='*'): + """ + Return a list of channels that have at least one subscriber + """ + return self.execute_command('PUBSUB CHANNELS', pattern) + + def pubsub_numpat(self): + """ + Returns the number of subscriptions to patterns + """ + return self.execute_command('PUBSUB NUMPAT') + + def pubsub_numsub(self, *args): + """ + Return a list of (channel, number of subscribers) tuples + for each channel given in ``*args`` + """ + return self.execute_command('PUBSUB NUMSUB', *args) + + def cluster(self, cluster_arg, *args): + return self.execute_command('CLUSTER %s' % cluster_arg.upper(), *args) + + def eval(self, script, numkeys, *keys_and_args): + """ + Execute the Lua ``script``, specifying the ``numkeys`` the script + will touch and the key names and argument values in ``keys_and_args``. + Returns the result of the script. + + In practice, use the object returned by ``register_script``. This + function exists purely for Redis API completion. + """ + return self.execute_command('EVAL', script, numkeys, *keys_and_args) + + def evalsha(self, sha, numkeys, *keys_and_args): + """ + Use the ``sha`` to execute a Lua script already registered via EVAL + or SCRIPT LOAD. Specify the ``numkeys`` the script will touch and the + key names and argument values in ``keys_and_args``. Returns the result + of the script. + + In practice, use the object returned by ``register_script``. This + function exists purely for Redis API completion. + """ + return self.execute_command('EVALSHA', sha, numkeys, *keys_and_args) + + def script_exists(self, *args): + """ + Check if a script exists in the script cache by specifying the SHAs of + each script as ``args``. Returns a list of boolean values indicating if + if each already script exists in the cache. + """ + return self.execute_command('SCRIPT EXISTS', *args) + + def script_flush(self): + "Flush all scripts from the script cache" + return self.execute_command('SCRIPT FLUSH') + + def script_kill(self): + "Kill the currently executing Lua script" + return self.execute_command('SCRIPT KILL') + + def script_load(self, script): + "Load a Lua ``script`` into the script cache. Returns the SHA." + return self.execute_command('SCRIPT LOAD', script) + + def register_script(self, script): + """ + Register a Lua ``script`` specifying the ``keys`` it will touch. + Returns a Script object that is callable and hides the complexity of + deal with scripts, keys, and shas. This is the preferred way to work + with Lua scripts. + """ + return Script(self, script) + + # GEO COMMANDS + def geoadd(self, name, *values): + """ + Add the specified geospatial items to the specified key identified + by the ``name`` argument. The Geospatial items are given as ordered + members of the ``values`` argument, each item or place is formed by + the triad longitude, latitude and name. + """ + if len(values) % 3 != 0: + raise DataError("GEOADD requires places with lon, lat and name" + " values") + return self.execute_command('GEOADD', name, *values) + + def geodist(self, name, place1, place2, unit=None): + """ + Return the distance between ``place1`` and ``place2`` members of the + ``name`` key. + The units must be one of the following : m, km mi, ft. By default + meters are used. + """ + pieces = [name, place1, place2] + if unit and unit not in ('m', 'km', 'mi', 'ft'): + raise DataError("GEODIST invalid unit") + elif unit: + pieces.append(unit) + return self.execute_command('GEODIST', *pieces) + + def geohash(self, name, *values): + """ + Return the geo hash string for each item of ``values`` members of + the specified key identified by the ``name`` argument. + """ + return self.execute_command('GEOHASH', name, *values) + + def geopos(self, name, *values): + """ + Return the positions of each item of ``values`` as members of + the specified key identified by the ``name`` argument. Each position + is represented by the pairs lon and lat. + """ + return self.execute_command('GEOPOS', name, *values) + + def georadius(self, name, longitude, latitude, radius, unit=None, + withdist=False, withcoord=False, withhash=False, count=None, + sort=None, store=None, store_dist=None): + """ + Return the members of the specified key identified by the + ``name`` argument which are within the borders of the area specified + with the ``latitude`` and ``longitude`` location and the maximum + distance from the center specified by the ``radius`` value. + + The units must be one of the following : m, km mi, ft. By default + + ``withdist`` indicates to return the distances of each place. + + ``withcoord`` indicates to return the latitude and longitude of + each place. + + ``withhash`` indicates to return the geohash string of each place. + + ``count`` indicates to return the number of elements up to N. + + ``sort`` indicates to return the places in a sorted way, ASC for + nearest to fairest and DESC for fairest to nearest. + + ``store`` indicates to save the places names in a sorted set named + with a specific key, each element of the destination sorted set is + populated with the score got from the original geo sorted set. + + ``store_dist`` indicates to save the places names in a sorted set + named with a specific key, instead of ``store`` the sorted set + destination score is set with the distance. + """ + return self._georadiusgeneric('GEORADIUS', + name, longitude, latitude, radius, + unit=unit, withdist=withdist, + withcoord=withcoord, withhash=withhash, + count=count, sort=sort, store=store, + store_dist=store_dist) + + def georadiusbymember(self, name, member, radius, unit=None, + withdist=False, withcoord=False, withhash=False, + count=None, sort=None, store=None, store_dist=None): + """ + This command is exactly like ``georadius`` with the sole difference + that instead of taking, as the center of the area to query, a longitude + and latitude value, it takes the name of a member already existing + inside the geospatial index represented by the sorted set. + """ + return self._georadiusgeneric('GEORADIUSBYMEMBER', + name, member, radius, unit=unit, + withdist=withdist, withcoord=withcoord, + withhash=withhash, count=count, + sort=sort, store=store, + store_dist=store_dist) + + def _georadiusgeneric(self, command, *args, **kwargs): + pieces = list(args) + if kwargs['unit'] and kwargs['unit'] not in ('m', 'km', 'mi', 'ft'): + raise DataError("GEORADIUS invalid unit") + elif kwargs['unit']: + pieces.append(kwargs['unit']) + else: + pieces.append('m',) + + for arg_name, byte_repr in ( + ('withdist', b'WITHDIST'), + ('withcoord', b'WITHCOORD'), + ('withhash', b'WITHHASH')): + if kwargs[arg_name]: + pieces.append(byte_repr) + + if kwargs['count']: + pieces.extend([b'COUNT', kwargs['count']]) + + if kwargs['sort']: + if kwargs['sort'] == 'ASC': + pieces.append(b'ASC') + elif kwargs['sort'] == 'DESC': + pieces.append(b'DESC') + else: + raise DataError("GEORADIUS invalid sort") + + if kwargs['store'] and kwargs['store_dist']: + raise DataError("GEORADIUS store and store_dist cant be set" + " together") + + if kwargs['store']: + pieces.extend([b'STORE', kwargs['store']]) + + if kwargs['store_dist']: + pieces.extend([b'STOREDIST', kwargs['store_dist']]) + + return self.execute_command(command, *pieces, **kwargs) + + # MODULE COMMANDS + def module_load(self, path): + """ + Loads the module from ``path``. + Raises ``ModuleError`` if a module is not found at ``path``. + """ + return self.execute_command('MODULE LOAD', path) + + def module_unload(self, name): + """ + Unloads the module ``name``. + Raises ``ModuleError`` if ``name`` is not in loaded modules. + """ + return self.execute_command('MODULE UNLOAD', name) + + def module_list(self): + """ + Returns a list of dictionaries containing the name and version of + all loaded modules. + """ + return self.execute_command('MODULE LIST') + + +class Script: + "An executable Lua script object returned by ``register_script``" + + def __init__(self, registered_client, script): + self.registered_client = registered_client + self.script = script + # Precalculate and store the SHA1 hex digest of the script. + + if isinstance(script, str): + # We need the encoding from the client in order to generate an + # accurate byte representation of the script + encoder = registered_client.connection_pool.get_encoder() + script = encoder.encode(script) + self.sha = hashlib.sha1(script).hexdigest() + + def __call__(self, keys=[], args=[], client=None): + "Execute the script, passing any required ``args``" + if client is None: + client = self.registered_client + args = tuple(keys) + tuple(args) + # make sure the Redis server knows about the script + from redis.client import Pipeline + if isinstance(client, Pipeline): + # Make sure the pipeline can register the script before executing. + client.scripts.add(self) + try: + return client.evalsha(self.sha, len(keys), *args) + except NoScriptError: + # Maybe the client is pointed to a different server than the client + # that created this instance? + # Overwrite the sha just in case there was a discrepancy. + self.sha = client.script_load(self.script) + return client.evalsha(self.sha, len(keys), *args) + + +class BitFieldOperation: + """ + Command builder for BITFIELD commands. + """ + def __init__(self, client, key, default_overflow=None): + self.client = client + self.key = key + self._default_overflow = default_overflow + self.reset() + + def reset(self): + """ + Reset the state of the instance to when it was constructed + """ + self.operations = [] + self._last_overflow = 'WRAP' + self.overflow(self._default_overflow or self._last_overflow) + + def overflow(self, overflow): + """ + Update the overflow algorithm of successive INCRBY operations + :param overflow: Overflow algorithm, one of WRAP, SAT, FAIL. See the + Redis docs for descriptions of these algorithmsself. + :returns: a :py:class:`BitFieldOperation` instance. + """ + overflow = overflow.upper() + if overflow != self._last_overflow: + self._last_overflow = overflow + self.operations.append(('OVERFLOW', overflow)) + return self + + def incrby(self, fmt, offset, increment, overflow=None): + """ + Increment a bitfield by a given amount. + :param fmt: format-string for the bitfield being updated, e.g. 'u8' + for an unsigned 8-bit integer. + :param offset: offset (in number of bits). If prefixed with a + '#', this is an offset multiplier, e.g. given the arguments + fmt='u8', offset='#2', the offset will be 16. + :param int increment: value to increment the bitfield by. + :param str overflow: overflow algorithm. Defaults to WRAP, but other + acceptable values are SAT and FAIL. See the Redis docs for + descriptions of these algorithms. + :returns: a :py:class:`BitFieldOperation` instance. + """ + if overflow is not None: + self.overflow(overflow) + + self.operations.append(('INCRBY', fmt, offset, increment)) + return self + + def get(self, fmt, offset): + """ + Get the value of a given bitfield. + :param fmt: format-string for the bitfield being read, e.g. 'u8' for + an unsigned 8-bit integer. + :param offset: offset (in number of bits). If prefixed with a + '#', this is an offset multiplier, e.g. given the arguments + fmt='u8', offset='#2', the offset will be 16. + :returns: a :py:class:`BitFieldOperation` instance. + """ + self.operations.append(('GET', fmt, offset)) + return self + + def set(self, fmt, offset, value): + """ + Set the value of a given bitfield. + :param fmt: format-string for the bitfield being read, e.g. 'u8' for + an unsigned 8-bit integer. + :param offset: offset (in number of bits). If prefixed with a + '#', this is an offset multiplier, e.g. given the arguments + fmt='u8', offset='#2', the offset will be 16. + :param int value: value to set at the given position. + :returns: a :py:class:`BitFieldOperation` instance. + """ + self.operations.append(('SET', fmt, offset, value)) + return self + + @property + def command(self): + cmd = ['BITFIELD', self.key] + for ops in self.operations: + cmd.extend(ops) + return cmd + + def execute(self): + """ + Execute the operation(s) in a single BITFIELD command. The return value + is a list of values corresponding to each operation. If the client + used to create this instance was a pipeline, the list of values + will be present within the pipeline's execute. + """ + command = self.command + self.reset() + return self.client.execute_command(*command) + + +class SentinalCommands: + """ + A class containing the commands specific to redis sentinal. This class is + to be used as a mixin. + """ + + def sentinel(self, *args): + "Redis Sentinel's SENTINEL command." + warnings.warn( + DeprecationWarning('Use the individual sentinel_* methods')) + + def sentinel_get_master_addr_by_name(self, service_name): + "Returns a (host, port) pair for the given ``service_name``" + return self.execute_command('SENTINEL GET-MASTER-ADDR-BY-NAME', + service_name) + + def sentinel_master(self, service_name): + "Returns a dictionary containing the specified masters state." + return self.execute_command('SENTINEL MASTER', service_name) + + def sentinel_masters(self): + "Returns a list of dictionaries containing each master's state." + return self.execute_command('SENTINEL MASTERS') + + def sentinel_monitor(self, name, ip, port, quorum): + "Add a new master to Sentinel to be monitored" + return self.execute_command('SENTINEL MONITOR', name, ip, port, quorum) + + def sentinel_remove(self, name): + "Remove a master from Sentinel's monitoring" + return self.execute_command('SENTINEL REMOVE', name) + + def sentinel_sentinels(self, service_name): + "Returns a list of sentinels for ``service_name``" + return self.execute_command('SENTINEL SENTINELS', service_name) + + def sentinel_set(self, name, option, value): + "Set Sentinel monitoring parameters for a given master" + return self.execute_command('SENTINEL SET', name, option, value) + + def sentinel_slaves(self, service_name): + "Returns a list of slaves for ``service_name``" + return self.execute_command('SENTINEL SLAVES', service_name) diff --git a/redis/sentinel.py b/redis/sentinel.py index f39e2e7d1a..d3213488c1 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -2,6 +2,7 @@ import weakref from redis.client import Redis +from redis.commands import SentinalCommands from redis.connection import ConnectionPool, Connection from redis.exceptions import (ConnectionError, ResponseError, ReadOnlyError, TimeoutError) @@ -132,7 +133,7 @@ def rotate_slaves(self): raise SlaveNotFoundError('No slave found for %r' % (self.service_name)) -class Sentinel: +class Sentinel(SentinalCommands, object): """ Redis Sentinel cluster client From b96af52e012bc002df97c4a82a5e4ad389cea3f3 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 18 Aug 2021 11:30:28 +0300 Subject: [PATCH 0135/1164] Updating CHANGES to list most recent changes (#1544) --- CHANGES | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/CHANGES b/CHANGES index 4c2ba40f7a..9a1df64432 100644 --- a/CHANGES +++ b/CHANGES @@ -12,6 +12,56 @@ @2014BDuck. #1307 * Added support for ABSTTL option of the RESTORE command available in Redis 5.0. Thanks @charettes. #1423 + * Migrated commands to a mixin. Thanks @chayim. #1534 + * Added support for ZUNION, available in Redis 6.2.0. Thanks + @AvitalFineRedis. #1522 + * Added support for CLIENT LIST with ID, available in Redis 6.2.0. + Thanks @chayim. #1505 + * Added support for MINID and LIMIT with xtrim, available in Reds 6.2.0. + Thanks @chayim. #1508 + * Implemented LMOVE and BLMOVE commands, available in Redis 6.2.0. + Thanks @chayim. #1504 + * Added GET argument to SET command, available in Redis 6.2.0. + Thanks @2014BDuck. #1412 + * Documentation fixes. Thanks @enjoy-binbin @jonher937. #1496 #1532 + * Added support for XAUTOCLAIM, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1529 + * Added IDLE support for XPENDING, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1523 + * Add a count parameter to lpop/rpop, available in Redis 6.2.0. + Thanks @wavenator. #1487 + * Added a (pypy) trove classifier for Python 3.9. + Thanks @D3X. #1535 + * Added ZINTER support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1520 + * Added ZINTER support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1520 + * Added ZDIFF and ZDIFFSTORE support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1518 + * Added ZRANGESTORE support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1521 + * Added LT and GT support for ZADD, available in Redis 6.2.0. + Thanks @chayim. #1509 + * Added ZRANDMEMBER support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1519 + * Added GETDEL support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1514 + * Added CLIENT KILL laddr filter, available in Redis 6.2.0. + Thanks @chayim. #1506 + * Added CLIENT UNPAUSE, available in Redis 6.2.0. + Thanks @chayim. #1512 + * Added NOMKSTREAM support for XADD, available in Redis 6.2.0. + Thanks @chayim. #1507 + * Added HRANDFIELD support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1513 + * Added CLIENT INFO support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1517 + * Added GETEX support, available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1515 + * Added support for COPY command, available in Redis 6.2.0. + Thanks @malinaa96. #1492 + * Added support for COPY command, available in Redis 6.2.0. + Thanks @malinaa96. #1492 * 3.5.3 (June 1, 2020) * Restore try/except clauses to __del__ methods. These will be removed in 4.0 when more explicit resource management if enforced. #1339 From e19a76c58f2a998d86e51c5a2a0f1db37563efce Mon Sep 17 00:00:00 2001 From: nbraun-amazon <85549956+nbraun-amazon@users.noreply.github.com> Date: Wed, 18 Aug 2021 12:06:09 +0300 Subject: [PATCH 0136/1164] Add retry mechanism with backoff (#1494) --- redis/backoff.py | 105 ++++++++++++++++++++++++++ redis/client.py | 177 +++++++++++++++++++++++++++----------------- redis/connection.py | 55 +++++++++----- redis/retry.py | 40 ++++++++++ tests/conftest.py | 3 + tests/test_retry.py | 66 +++++++++++++++++ 6 files changed, 360 insertions(+), 86 deletions(-) create mode 100644 redis/backoff.py create mode 100644 redis/retry.py create mode 100644 tests/test_retry.py diff --git a/redis/backoff.py b/redis/backoff.py new file mode 100644 index 0000000000..9162778cc0 --- /dev/null +++ b/redis/backoff.py @@ -0,0 +1,105 @@ +from abc import ABC, abstractmethod +import random + + +class AbstractBackoff(ABC): + """Backoff interface""" + + def reset(self): + """ + Reset internal state before an operation. + `reset` is called once at the beginning of + every call to `Retry.call_with_retry` + """ + pass + + @abstractmethod + def compute(self, failures): + """Compute backoff in seconds upon failure""" + pass + + +class ConstantBackoff(AbstractBackoff): + """Constant backoff upon failure""" + + def __init__(self, backoff): + """`backoff`: backoff time in seconds""" + self._backoff = backoff + + def compute(self, failures): + return self._backoff + + +class NoBackoff(ConstantBackoff): + """No backoff upon failure""" + + def __init__(self): + super().__init__(0) + + +class ExponentialBackoff(AbstractBackoff): + """Exponential backoff upon failure""" + + def __init__(self, cap, base): + """ + `cap`: maximum backoff time in seconds + `base`: base backoff time in seconds + """ + self._cap = cap + self._base = base + + def compute(self, failures): + return min(self._cap, self._base * 2 ** failures) + + +class FullJitterBackoff(AbstractBackoff): + """Full jitter backoff upon failure""" + + def __init__(self, cap, base): + """ + `cap`: maximum backoff time in seconds + `base`: base backoff time in seconds + """ + self._cap = cap + self._base = base + + def compute(self, failures): + return random.uniform(0, min(self._cap, self._base * 2 ** failures)) + + +class EqualJitterBackoff(AbstractBackoff): + """Equal jitter backoff upon failure""" + + def __init__(self, cap, base): + """ + `cap`: maximum backoff time in seconds + `base`: base backoff time in seconds + """ + self._cap = cap + self._base = base + + def compute(self, failures): + temp = min(self._cap, self._base * 2 ** failures) / 2 + return temp + random.uniform(0, temp) + + +class DecorrelatedJitterBackoff(AbstractBackoff): + """Decorrelated jitter backoff upon failure""" + + def __init__(self, cap, base): + """ + `cap`: maximum backoff time in seconds + `base`: base backoff time in seconds + """ + self._cap = cap + self._base = base + self._previous_backoff = 0 + + def reset(self): + self._previous_backoff = 0 + + def compute(self, failures): + max_backoff = max(self._base, self._previous_backoff * 3) + temp = random.uniform(self._base, max_backoff) + self._previous_backoff = min(self._cap, temp) + return self._previous_backoff diff --git a/redis/client.py b/redis/client.py index 741c2d0226..ab9246d160 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1,4 +1,5 @@ from itertools import chain +import copy import datetime import hashlib import re @@ -758,7 +759,13 @@ def __init__(self, host='localhost', port=6379, ssl_cert_reqs='required', ssl_ca_certs=None, ssl_check_hostname=False, max_connections=None, single_connection_client=False, - health_check_interval=0, client_name=None, username=None): + health_check_interval=0, client_name=None, username=None, + retry=None): + """ + Initialize a new Redis client. + To specify a retry policy, first set `retry_on_timeout` to `True` + then set `retry` to a valid `Retry` object + """ if not connection_pool: if charset is not None: warnings.warn(DeprecationWarning( @@ -778,6 +785,7 @@ def __init__(self, host='localhost', port=6379, 'encoding_errors': encoding_errors, 'decode_responses': decode_responses, 'retry_on_timeout': retry_on_timeout, + 'retry': copy.deepcopy(retry), 'max_connections': max_connections, 'health_check_interval': health_check_interval, 'client_name': client_name @@ -940,21 +948,41 @@ def close(self): self.connection = None self.connection_pool.release(conn) + def _send_command_parse_response(self, + conn, + command_name, + *args, + **options): + """ + Send a command and parse the response + """ + conn.send_command(*args) + return self.parse_response(conn, command_name, **options) + + def _disconnect_raise(self, conn, error): + """ + Close the connection and raise an exception + if retry_on_timeout is not set or the error + is not a TimeoutError + """ + conn.disconnect() + if not (conn.retry_on_timeout and isinstance(error, TimeoutError)): + raise error + # COMMAND EXECUTION AND PROTOCOL PARSING def execute_command(self, *args, **options): "Execute a command and return a parsed response" pool = self.connection_pool command_name = args[0] conn = self.connection or pool.get_connection(command_name, **options) + try: - conn.send_command(*args) - return self.parse_response(conn, command_name, **options) - except (ConnectionError, TimeoutError) as e: - conn.disconnect() - if not (conn.retry_on_timeout and isinstance(e, TimeoutError)): - raise - conn.send_command(*args) - return self.parse_response(conn, command_name, **options) + return conn.retry.call_with_retry( + lambda: self._send_command_parse_response(conn, + command_name, + *args, + **options), + lambda error: self._disconnect_raise(conn, error)) finally: if not self.connection: pool.release(conn) @@ -1142,24 +1170,31 @@ def execute_command(self, *args): kwargs = {'check_health': not self.subscribed} self._execute(connection, connection.send_command, *args, **kwargs) - def _execute(self, connection, command, *args, **kwargs): - try: - return command(*args, **kwargs) - except (ConnectionError, TimeoutError) as e: - connection.disconnect() - if not (connection.retry_on_timeout and - isinstance(e, TimeoutError)): - raise - # Connect manually here. If the Redis server is down, this will - # fail and raise a ConnectionError as desired. - connection.connect() - # the ``on_connect`` callback should haven been called by the - # connection to resubscribe us to any channels and patterns we were - # previously listening to - return command(*args, **kwargs) + def _disconnect_raise_connect(self, conn, error): + """ + Close the connection and raise an exception + if retry_on_timeout is not set or the error + is not a TimeoutError. Otherwise, try to reconnect + """ + conn.disconnect() + if not (conn.retry_on_timeout and isinstance(error, TimeoutError)): + raise error + conn.connect() + + def _execute(self, conn, command, *args, **kwargs): + """ + Connect manually upon disconnection. If the Redis server is down, + this will fail and raise a ConnectionError as desired. + After reconnection, the ``on_connect`` callback should have been + called by the # connection to resubscribe us to any channels and + patterns we were previously listening to + """ + return conn.retry.call_with_retry( + lambda: command(*args, **kwargs), + lambda error: self._disconnect_raise_connect(conn, error)) def parse_response(self, block=True, timeout=0): - "Parse the response from a publish/subscribe command" + """Parse the response from a publish/subscribe command""" conn = self.connection if conn is None: raise RuntimeError( @@ -1499,6 +1534,27 @@ def execute_command(self, *args, **kwargs): return self.immediate_execute_command(*args, **kwargs) return self.pipeline_execute_command(*args, **kwargs) + def _disconnect_reset_raise(self, conn, error): + """ + Close the connection, reset watching state and + raise an exception if we were watching, + retry_on_timeout is not set, + or the error is not a TimeoutError + """ + conn.disconnect() + # if we were already watching a variable, the watch is no longer + # valid since this connection has died. raise a WatchError, which + # indicates the user should retry this transaction. + if self.watching: + self.reset() + raise WatchError("A ConnectionError occurred on while " + "watching one or more keys") + # if retry_on_timeout is not set, or the error is not + # a TimeoutError, raise it + if not (conn.retry_on_timeout and isinstance(error, TimeoutError)): + self.reset() + raise + def immediate_execute_command(self, *args, **options): """ Execute a command immediately, but don't auto-retry on a @@ -1513,33 +1569,13 @@ def immediate_execute_command(self, *args, **options): conn = self.connection_pool.get_connection(command_name, self.shard_hint) self.connection = conn - try: - conn.send_command(*args) - return self.parse_response(conn, command_name, **options) - except (ConnectionError, TimeoutError) as e: - conn.disconnect() - # if we were already watching a variable, the watch is no longer - # valid since this connection has died. raise a WatchError, which - # indicates the user should retry this transaction. - if self.watching: - self.reset() - raise WatchError("A ConnectionError occurred on while " - "watching one or more keys") - # if retry_on_timeout is not set, or the error is not - # a TimeoutError, raise it - if not (conn.retry_on_timeout and isinstance(e, TimeoutError)): - self.reset() - raise - - # retry_on_timeout is set, this is a TimeoutError and we are not - # already WATCHing any variables. retry the command. - try: - conn.send_command(*args) - return self.parse_response(conn, command_name, **options) - except (ConnectionError, TimeoutError): - # a subsequent failure should simply be raised - self.reset() - raise + + return conn.retry.call_with_retry( + lambda: self._send_command_parse_response(conn, + command_name, + *args, + **options), + lambda error: self._disconnect_reset_raise(conn, error)) def pipeline_execute_command(self, *args, **options): """ @@ -1672,6 +1708,25 @@ def load_scripts(self): if not exist: s.sha = immediate('SCRIPT LOAD', s.script) + def _disconnect_raise_reset(self, conn, error): + """ + Close the connection, raise an exception if we were watching, + and raise an exception if retry_on_timeout is not set, + or the error is not a TimeoutError + """ + conn.disconnect() + # if we were watching a variable, the watch is no longer valid + # since this connection has died. raise a WatchError, which + # indicates the user should retry this transaction. + if self.watching: + raise WatchError("A ConnectionError occurred on while " + "watching one or more keys") + # if retry_on_timeout is not set, or the error is not + # a TimeoutError, raise it + if not (conn.retry_on_timeout and isinstance(error, TimeoutError)): + self.reset() + raise + def execute(self, raise_on_error=True): "Execute all the commands in the current pipeline" stack = self.command_stack @@ -1693,21 +1748,9 @@ def execute(self, raise_on_error=True): self.connection = conn try: - return execute(conn, stack, raise_on_error) - except (ConnectionError, TimeoutError) as e: - conn.disconnect() - # if we were watching a variable, the watch is no longer valid - # since this connection has died. raise a WatchError, which - # indicates the user should retry this transaction. - if self.watching: - raise WatchError("A ConnectionError occurred on while " - "watching one or more keys") - # if retry_on_timeout is not set, or the error is not - # a TimeoutError, raise it - if not (conn.retry_on_timeout and isinstance(e, TimeoutError)): - raise - # retry a TimeoutError when retry_on_timeout is set - return execute(conn, stack, raise_on_error) + return conn.retry.call_with_retry( + lambda: execute(conn, stack, raise_on_error), + lambda error: self._disconnect_raise_reset(conn, error)) finally: self.reset() diff --git a/redis/connection.py b/redis/connection.py index 4a855b3568..e47e3c76a0 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -3,6 +3,7 @@ from time import time from queue import LifoQueue, Empty, Full from urllib.parse import parse_qs, unquote, urlparse +import copy import errno import io import os @@ -28,6 +29,8 @@ ModuleError, ) from redis.utils import HIREDIS_AVAILABLE, str_if_bytes +from redis.backoff import NoBackoff +from redis.retry import Retry try: import ssl @@ -499,7 +502,13 @@ def __init__(self, host='localhost', port=6379, db=0, password=None, socket_type=0, retry_on_timeout=False, encoding='utf-8', encoding_errors='strict', decode_responses=False, parser_class=DefaultParser, socket_read_size=65536, - health_check_interval=0, client_name=None, username=None): + health_check_interval=0, client_name=None, username=None, + retry=None): + """ + Initialize a new Connection. + To specify a retry policy, first set `retry_on_timeout` to `True` + then set `retry` to a valid `Retry` object + """ self.pid = os.getpid() self.host = host self.port = int(port) @@ -513,6 +522,14 @@ def __init__(self, host='localhost', port=6379, db=0, password=None, self.socket_keepalive_options = socket_keepalive_options or {} self.socket_type = socket_type self.retry_on_timeout = retry_on_timeout + if retry_on_timeout: + if retry is None: + self.retry = Retry(NoBackoff(), 1) + else: + # deep-copy the Retry object as it is mutable + self.retry = copy.deepcopy(retry) + else: + self.retry = Retry(NoBackoff(), 0) self.health_check_interval = health_check_interval self.next_health_check = 0 self.encoder = Encoder(encoding, encoding_errors, decode_responses) @@ -673,23 +690,23 @@ def disconnect(self): pass self._sock = None + def _send_ping(self): + """Send PING, expect PONG in return""" + self.send_command('PING', check_health=False) + if str_if_bytes(self.read_response()) != 'PONG': + raise ConnectionError('Bad response from PING health check') + + def _ping_failed(self, error): + """Function to call when PING fails""" + self.disconnect() + def check_health(self): - "Check the health of the connection with a PING/PONG" + """Check the health of the connection with a PING/PONG""" if self.health_check_interval and time() > self.next_health_check: - try: - self.send_command('PING', check_health=False) - if str_if_bytes(self.read_response()) != 'PONG': - raise ConnectionError( - 'Bad response from PING health check') - except (ConnectionError, TimeoutError): - self.disconnect() - self.send_command('PING', check_health=False) - if str_if_bytes(self.read_response()) != 'PONG': - raise ConnectionError( - 'Bad response from PING health check') + self.retry.call_with_retry(self._send_ping, self._ping_failed) def send_packed_command(self, command, check_health=True): - "Send an already packed command to the Redis server" + """Send an already packed command to the Redis server""" if not self._sock: self.connect() # guard against health check recursion @@ -717,12 +734,12 @@ def send_packed_command(self, command, check_health=True): raise def send_command(self, *args, **kwargs): - "Pack and send a command to the Redis server" + """Pack and send a command to the Redis server""" self.send_packed_command(self.pack_command(*args), check_health=kwargs.get('check_health', True)) def can_read(self, timeout=0): - "Poll the socket to see if there's data that can be read." + """Poll the socket to see if there's data that can be read.""" sock = self._sock if not sock: self.connect() @@ -730,7 +747,7 @@ def can_read(self, timeout=0): return self._parser.can_read(timeout) def read_response(self): - "Read the response from a previously sent command" + """Read the response from a previously sent command""" try: response = self._parser.read_response() except socket.timeout: @@ -753,7 +770,7 @@ def read_response(self): return response def pack_command(self, *args): - "Pack a series of arguments into the Redis protocol" + """Pack a series of arguments into the Redis protocol""" output = [] # the client might have included 1 or more literal arguments in # the command name, e.g., 'CONFIG GET'. The Redis server expects these @@ -787,7 +804,7 @@ def pack_command(self, *args): return output def pack_commands(self, commands): - "Pack multiple commands into the Redis protocol" + """Pack multiple commands into the Redis protocol""" output = [] pieces = [] buffer_length = 0 diff --git a/redis/retry.py b/redis/retry.py new file mode 100644 index 0000000000..cd06a23e3d --- /dev/null +++ b/redis/retry.py @@ -0,0 +1,40 @@ +from time import sleep + +from redis.exceptions import ConnectionError, TimeoutError + + +class Retry: + """Retry a specific number of times after a failure""" + + def __init__(self, backoff, retries, + supported_errors=(ConnectionError, TimeoutError)): + """ + Initialize a `Retry` object with a `Backoff` object + that retries a maximum of `retries` times. + You can specify the types of supported errors which trigger + a retry with the `supported_errors` parameter. + """ + self._backoff = backoff + self._retries = retries + self._supported_errors = supported_errors + + def call_with_retry(self, do, fail): + """ + Execute an operation that might fail and returns its result, or + raise the exception that was thrown depending on the `Backoff` object. + `do`: the operation to call. Expects no argument. + `fail`: the failure handler, expects the last error that was thrown + """ + self._backoff.reset() + failures = 0 + while True: + try: + return do() + except self._supported_errors as error: + failures += 1 + fail(error) + if failures > self._retries: + raise error + backoff = self._backoff.compute(failures) + if backoff > 0: + sleep(backoff) diff --git a/tests/conftest.py b/tests/conftest.py index cd4d4894a3..711f9e5057 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from redis.backoff import NoBackoff +from redis.retry import Retry import pytest import random import redis @@ -107,6 +109,7 @@ def r2(request): def _gen_cluster_mock_resp(r, response): connection = Mock() + connection.retry = Retry(NoBackoff(), 0) connection.read_response.return_value = response r.connection = connection return r diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000000..24d9683f17 --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,66 @@ +from redis.backoff import NoBackoff +import pytest + +from redis.exceptions import ConnectionError +from redis.connection import Connection +from redis.retry import Retry + + +class BackoffMock: + def __init__(self): + self.reset_calls = 0 + self.calls = 0 + + def reset(self): + self.reset_calls += 1 + + def compute(self, failures): + self.calls += 1 + return 0 + + +class TestConnectionConstructorWithRetry: + "Test that the Connection constructor properly handles Retry objects" + + @pytest.mark.parametrize("retry_on_timeout", [False, True]) + def test_retry_on_timeout_boolean(self, retry_on_timeout): + c = Connection(retry_on_timeout=retry_on_timeout) + assert c.retry_on_timeout == retry_on_timeout + assert isinstance(c.retry, Retry) + assert c.retry._retries == (1 if retry_on_timeout else 0) + + @pytest.mark.parametrize("retries", range(10)) + def test_retry_on_timeout_retry(self, retries): + retry_on_timeout = retries > 0 + c = Connection(retry_on_timeout=retry_on_timeout, + retry=Retry(NoBackoff(), retries)) + assert c.retry_on_timeout == retry_on_timeout + assert isinstance(c.retry, Retry) + assert c.retry._retries == retries + + +class TestRetry: + "Test that Retry calls backoff and retries the expected number of times" + + def setup_method(self, test_method): + self.actual_attempts = 0 + self.actual_failures = 0 + + def _do(self): + self.actual_attempts += 1 + raise ConnectionError() + + def _fail(self, error): + self.actual_failures += 1 + + @pytest.mark.parametrize("retries", range(10)) + def test_retry(self, retries): + backoff = BackoffMock() + retry = Retry(backoff, retries) + with pytest.raises(ConnectionError): + retry.call_with_retry(self._do, self._fail) + + assert self.actual_attempts == 1 + retries + assert self.actual_failures == 1 + retries + assert backoff.reset_calls == 1 + assert backoff.calls == retries From 8cfea4137851fa49c7d211b782800dd696b67c39 Mon Sep 17 00:00:00 2001 From: Karthikeyan Singaravelan Date: Sun, 29 Aug 2021 13:49:45 +0530 Subject: [PATCH 0137/1164] Use Version instead of StrictVersion since distutils is deprecated. (#1552) --- redis/connection.py | 10 +++++----- tests/conftest.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/redis/connection.py b/redis/connection.py index e47e3c76a0..de30f0c638 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -1,4 +1,4 @@ -from distutils.version import StrictVersion +from packaging.version import Version from itertools import chain from time import time from queue import LifoQueue, Empty, Full @@ -54,13 +54,13 @@ if HIREDIS_AVAILABLE: import hiredis - hiredis_version = StrictVersion(hiredis.__version__) + hiredis_version = Version(hiredis.__version__) HIREDIS_SUPPORTS_CALLABLE_ERRORS = \ - hiredis_version >= StrictVersion('0.1.3') + hiredis_version >= Version('0.1.3') HIREDIS_SUPPORTS_BYTE_BUFFER = \ - hiredis_version >= StrictVersion('0.1.4') + hiredis_version >= Version('0.1.4') HIREDIS_SUPPORTS_ENCODING_ERRORS = \ - hiredis_version >= StrictVersion('1.0.0') + hiredis_version >= Version('1.0.0') if not HIREDIS_SUPPORTS_BYTE_BUFFER: msg = ("redis-py works best with hiredis >= 0.1.4. You're running " diff --git a/tests/conftest.py b/tests/conftest.py index 711f9e5057..fe304c2cc2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest import random import redis -from distutils.version import StrictVersion +from packaging.version import Version from redis.connection import parse_url from unittest.mock import Mock from urllib.parse import urlparse @@ -44,7 +44,7 @@ def pytest_sessionstart(session): def skip_if_server_version_lt(min_version): redis_version = REDIS_INFO["version"] - check = StrictVersion(redis_version) < StrictVersion(min_version) + check = Version(redis_version) < Version(min_version) return pytest.mark.skipif( check, reason="Redis version required >= {}".format(min_version)) @@ -52,7 +52,7 @@ def skip_if_server_version_lt(min_version): def skip_if_server_version_gte(min_version): redis_version = REDIS_INFO["version"] - check = StrictVersion(redis_version) >= StrictVersion(min_version) + check = Version(redis_version) >= Version(min_version) return pytest.mark.skipif( check, reason="Redis version required < {}".format(min_version)) @@ -183,7 +183,7 @@ def wait_for_command(client, monitor, command): # if we find a command with our key before the command we're waiting # for, something went wrong redis_version = REDIS_INFO["version"] - if StrictVersion(redis_version) >= StrictVersion('5.0.0'): + if Version(redis_version) >= Version('5.0.0'): id_str = str(client.client_id()) else: id_str = '%08x' % random.randrange(2**32) From 7c77883596e9e28c2d04298bf15ad9f947dd907f Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 29 Aug 2021 11:21:01 +0300 Subject: [PATCH 0138/1164] Merged new sentinel commands from #834 (#1550) * Merged new sentinel commands from #835 Thanks you @otherpirate for the contribution! * Added an execute wrapper and tests. The tests ensure that the function is called. Nothing more since we do not currently have enough testing support for sentinel --- redis/client.py | 4 ++++ redis/commands.py | 53 +++++++++++++++++++++++++++++++++++++++++- redis/sentinel.py | 21 +++++++++++++++-- tests/test_sentinel.py | 23 ++++++++++++++++-- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/redis/client.py b/redis/client.py index ab9246d160..f6ca071ed4 100755 --- a/redis/client.py +++ b/redis/client.py @@ -675,10 +675,14 @@ class Redis(Commands, object): 'SCRIPT FLUSH': bool_ok, 'SCRIPT KILL': bool_ok, 'SCRIPT LOAD': str_if_bytes, + 'SENTINEL CKQUORUM': bool_ok, + 'SENTINEL FAILOVER': bool_ok, + 'SENTINEL FLUSHCONFIG': bool_ok, 'SENTINEL GET-MASTER-ADDR-BY-NAME': parse_sentinel_get_master, 'SENTINEL MASTER': parse_sentinel_master, 'SENTINEL MASTERS': parse_sentinel_masters, 'SENTINEL MONITOR': bool_ok, + 'SENTINEL RESET': bool_ok, 'SENTINEL REMOVE': bool_ok, 'SENTINEL SENTINELS': parse_sentinel_slaves_and_sentinels, 'SENTINEL SET': bool_ok, diff --git a/redis/commands.py b/redis/commands.py index 003b0f1bcc..3d5670e1f1 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2950,7 +2950,7 @@ def execute(self): return self.client.execute_command(*command) -class SentinalCommands: +class SentinelCommands: """ A class containing the commands specific to redis sentinal. This class is to be used as a mixin. @@ -2993,3 +2993,54 @@ def sentinel_set(self, name, option, value): def sentinel_slaves(self, service_name): "Returns a list of slaves for ``service_name``" return self.execute_command('SENTINEL SLAVES', service_name) + + def sentinel_reset(self, pattern): + """ + This command will reset all the masters with matching name. + The pattern argument is a glob-style pattern. + + The reset process clears any previous state in a master (including a + failover in progress), and removes every slave and sentinel already + discovered and associated with the master. + """ + return self.execute_command('SENTINEL RESET', pattern, once=True) + + def sentinel_failover(self, new_master_name): + """ + Force a failover as if the master was not reachable, and without + asking for agreement to other Sentinels (however a new version of the + configuration will be published so that the other Sentinels will + update their configurations). + """ + return self.execute_command('SENTINEL FAILOVER', new_master_name) + + def sentinel_ckquorum(self, new_master_name): + """ + Check if the current Sentinel configuration is able to reach the + quorum needed to failover a master, and the majority needed to + authorize the failover. + + This command should be used in monitoring systems to check if a + Sentinel deployment is ok. + """ + return self.execute_command('SENTINEL CKQUORUM', + new_master_name, + once=True) + + def sentinel_flushconfig(self): + """ + Force Sentinel to rewrite its configuration on disk, including the + current Sentinel state. + + Normally Sentinel rewrites the configuration every time something + changes in its state (in the context of the subset of the state which + is persisted on disk across restart). + However sometimes it is possible that the configuration file is lost + because of operation errors, disk failures, package upgrade scripts or + configuration managers. In those cases a way to to force Sentinel to + rewrite the configuration file is handy. + + This command works even if the previous configuration file is + completely missing. + """ + return self.execute_command('SENTINEL FLUSHCONFIG') diff --git a/redis/sentinel.py b/redis/sentinel.py index d3213488c1..6456b8e868 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -2,7 +2,7 @@ import weakref from redis.client import Redis -from redis.commands import SentinalCommands +from redis.commands import SentinelCommands from redis.connection import ConnectionPool, Connection from redis.exceptions import (ConnectionError, ResponseError, ReadOnlyError, TimeoutError) @@ -133,7 +133,7 @@ def rotate_slaves(self): raise SlaveNotFoundError('No slave found for %r' % (self.service_name)) -class Sentinel(SentinalCommands, object): +class Sentinel(SentinelCommands, object): """ Redis Sentinel cluster client @@ -179,6 +179,23 @@ def __init__(self, sentinels, min_other_sentinels=0, sentinel_kwargs=None, self.min_other_sentinels = min_other_sentinels self.connection_kwargs = connection_kwargs + def execute_command(self, *args, **kwargs): + """ + Execute Sentinel command in sentinel nodes. + once - If set to True, then execute the resulting command on a single + node at random, rather than across the entire sentinel cluster. + """ + once = bool(kwargs.get('once', False)) + if 'once' in kwargs.keys(): + kwargs.pop('once') + + if once: + for sentinel in self.sentinels: + sentinel.execute_command(*args, **kwargs) + else: + random.choice(self.sentinels).execute_command(*args, **kwargs) + return True + def __repr__(self): sentinel_addresses = [] for sentinel in self.sentinels: diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py index 64a7c47d3a..54cf262c43 100644 --- a/tests/test_sentinel.py +++ b/tests/test_sentinel.py @@ -30,9 +30,15 @@ def sentinel_slaves(self, master_name): return [] return self.cluster.slaves + def execute_command(self, *args, **kwargs): + # wrapper purely to validate the calls don't explode + from redis.client import bool_ok + return bool_ok + class SentinelTestCluster: - def __init__(self, service_name='mymaster', ip='127.0.0.1', port=6379): + def __init__(self, servisentinel_ce_name='mymaster', ip='127.0.0.1', + port=6379): self.clients = {} self.master = { 'ip': ip, @@ -42,7 +48,7 @@ def __init__(self, service_name='mymaster', ip='127.0.0.1', port=6379): 'is_odown': False, 'num-other-sentinels': 0, } - self.service_name = service_name + self.service_name = servisentinel_ce_name self.slaves = [] self.nodes_down = set() self.nodes_timeout = set() @@ -198,3 +204,16 @@ def test_slave_round_robin(cluster, sentinel, master_ip): assert next(rotator) == (master_ip, 6379) with pytest.raises(SlaveNotFoundError): next(rotator) + + +def test_ckquorum(cluster, sentinel): + assert sentinel.sentinel_ckquorum("mymaster") + + +def test_flushconfig(cluster, sentinel): + assert sentinel.sentinel_flushconfig() + + +def test_reset(cluster, sentinel): + cluster.master['is_odown'] = True + assert sentinel.sentinel_reset('mymaster') From 295b547fb0fe67cef7c21f84f98bbfad4ca80d08 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Sun, 29 Aug 2021 11:21:58 +0300 Subject: [PATCH 0139/1164] Support MINID and LIMIT on XADD (#1548) * MINID and LIMIT --- redis/commands.py | 22 ++++++++++++++++--- tests/test_commands.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 3d5670e1f1..5aae14a808 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1640,17 +1640,25 @@ def xack(self, name, groupname, *ids): return self.execute_command('XACK', name, groupname, *ids) def xadd(self, name, fields, id='*', maxlen=None, approximate=True, - nomkstream=False): + nomkstream=False, minid=None, limit=None): """ Add to a stream. name: name of the stream fields: dict of field/value pairs to insert into the stream id: Location to insert this record. By default it is appended. - maxlen: truncate old stream members beyond this size + maxlen: truncate old stream members beyond this size. + Can't be specify with minid. + minid: the minimum id in the stream to query. + Can't be specify with maxlen. approximate: actual stream length may be slightly more than maxlen nomkstream: When set to true, do not make a stream + limit: specifies the maximum number of entries to retrieve """ pieces = [] + if maxlen is not None and minid is not None: + raise DataError("Only one of ```maxlen``` or ```minid```", + "may be specified") + if maxlen is not None: if not isinstance(maxlen, int) or maxlen < 1: raise DataError('XADD maxlen must be a positive integer') @@ -1658,6 +1666,14 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True, if approximate: pieces.append(b'~') pieces.append(str(maxlen)) + if minid is not None: + pieces.append(b'MINID') + if approximate: + pieces.append(b'~') + pieces.append(minid) + if limit is not None: + pieces.append(b"LIMIT") + pieces.append(limit) if nomkstream: pieces.append(b'NOMKSTREAM') pieces.append(id) @@ -2002,7 +2018,7 @@ def xtrim(self, name, maxlen=None, approximate=True, minid=None, name: name of the stream. maxlen: truncate old stream messages beyond this size approximate: actual stream length may be slightly more than maxlen - minin: the minimum id in the stream to query + minid: the minimum id in the stream to query limit: specifies the maximum number of entries to retrieve """ pieces = [] diff --git a/tests/test_commands.py b/tests/test_commands.py index 3c87dd9dd6..d22d72abcf 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2422,6 +2422,56 @@ def test_xadd_nomkstream(self, r): r.xadd(stream, {'some': 'other'}, nomkstream=True) assert r.xlen(stream) == 3 + @skip_if_server_version_lt('6.2.0') + def test_xadd_minlen_and_limit(self, r): + stream = 'stream' + + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + + # Future self: No limits without approximate, according to the api + with pytest.raises(redis.ResponseError): + assert r.xadd(stream, {'foo': 'bar'}, maxlen=3, + approximate=False, limit=2) + + # limit can not be provided without maxlen or minid + with pytest.raises(redis.ResponseError): + assert r.xadd(stream, {'foo': 'bar'}, limit=2) + + # maxlen with a limit + assert r.xadd(stream, {'foo': 'bar'}, maxlen=3, + approximate=True, limit=2) + r.delete(stream) + + # maxlen and minid can not be provided together + with pytest.raises(redis.DataError): + assert r.xadd(stream, {'foo': 'bar'}, maxlen=3, + minid="sometestvalue") + + # minid with a limit + m1 = r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + assert r.xadd(stream, {'foo': 'bar'}, approximate=True, + minid=m1, limit=3) + + # pure minid + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + m4 = r.xadd(stream, {'foo': 'bar'}) + assert r.xadd(stream, {'foo': 'bar'}, approximate=False, minid=m4) + + # minid approximate + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + m3 = r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + assert r.xadd(stream, {'foo': 'bar'}, approximate=True, minid=m3) + @skip_if_server_version_lt('6.2.0') def test_xautoclaim(self, r): stream = 'stream' From 5964d700beb9a6b195d64430b0a655e6aa6fd721 Mon Sep 17 00:00:00 2001 From: Jiekun <2014bduck@gmail.com> Date: Sun, 29 Aug 2021 16:32:18 +0800 Subject: [PATCH 0140/1164] #1434 Added support for ZMSCORE new in Redis 6.2 RC (#1437) * #1434 Added zmscore command support * #1434 Fixed typo and doc * #1434 Set [] as default value for members arg in zmscore func * #1434 Set None as default value for members arg in zmscore func * #1434 Removed default value for members arg in zmscore func * Fixed flake8 formatting Co-authored-by: jiekun.zhu --- redis/client.py | 6 ++++++ redis/commands.py | 14 ++++++++++++++ tests/test_commands.py | 11 +++++++++++ 3 files changed, 31 insertions(+) diff --git a/redis/client.py b/redis/client.py index f6ca071ed4..939c32730b 100755 --- a/redis/client.py +++ b/redis/client.py @@ -390,6 +390,11 @@ def parse_zscan(response, **options): return int(cursor), list(zip(it, map(score_cast_func, it))) +def parse_zmscore(response, **options): + # zmscore: list of scores (double precision floating point number) or nil + return [float(score) if score is not None else None for score in response] + + def parse_slowlog_get(response, **options): space = ' ' if options.get('decode_responses', False) else b' ' return [{ @@ -705,6 +710,7 @@ class Redis(Commands, object): 'XPENDING': parse_xpending, 'ZADD': parse_zadd, 'ZSCAN': parse_zscan, + 'ZMSCORE': parse_zmscore, } @classmethod diff --git a/redis/commands.py b/redis/commands.py index 5aae14a808..f940bcc071 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2461,6 +2461,20 @@ def zunionstore(self, dest, keys, aggregate=None): """ return self._zaggregate('ZUNIONSTORE', dest, keys, aggregate) + def zmscore(self, key, members): + """ + Returns the scores associated with the specified members + in the sorted set stored at key. + ``members`` should be a list of the member name. + Return type is a list of score. + If the member does not exist, a None will be returned + in corresponding position. + """ + if not members: + raise DataError('ZMSCORE members must be a non-empty list') + pieces = [key] + members + return self.execute_command('ZMSCORE', *pieces) + def _zaggregate(self, command, dest, keys, aggregate=None, **options): pieces = [command] diff --git a/tests/test_commands.py b/tests/test_commands.py index d22d72abcf..1d829d6261 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1865,6 +1865,17 @@ def test_zunionstore_with_weight(self, r): assert r.zrange('d', 0, -1, withscores=True) == \ [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] + @skip_if_server_version_lt('6.1.240') + def test_zmscore(self, r): + with pytest.raises(exceptions.DataError): + r.zmscore('invalid_key', []) + + assert r.zmscore('invalid_key', ['invalid_member']) == [None] + + r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3.5}) + assert r.zmscore('a', ['a1', 'a2', 'a3', 'a4']) == \ + [1.0, 2.0, 3.5, None] + # HYPERLOGLOG TESTS @skip_if_server_version_lt('2.8.9') def test_pfadd(self, r): From 9f82778b78b2e4fd1482255edc91d10c4dda2988 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Sun, 29 Aug 2021 11:36:50 +0300 Subject: [PATCH 0141/1164] Stralgo (#1528) * add support to STRALDO command * add tests * skip if version .. * new line * lower case * fix comments * callback * change to get --- redis/client.py | 29 ++++++++++++++++++++++++++ redis/commands.py | 47 ++++++++++++++++++++++++++++++++++++++++++ tests/test_commands.py | 43 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) diff --git a/redis/client.py b/redis/client.py index 939c32730b..3a9a5b6b1b 100755 --- a/redis/client.py +++ b/redis/client.py @@ -410,6 +410,34 @@ def parse_slowlog_get(response, **options): } for item in response] +def parse_stralgo(response, **options): + """ + Parse the response from `STRALGO` command. + Without modifiers the returned value is string. + When LEN is given the command returns the length of the result + (i.e integer). + When IDX is given the command returns a dictionary with the LCS + length and all the ranges in both the strings, start and end + offset for each string, where there are matches. + When WITHMATCHLEN is given, each array representing a match will + also have the length of the match at the beginning of the array. + """ + if options.get('len', False): + return int(response) + if options.get('idx', False): + if options.get('withmatchlen', False): + matches = [[(int(match[-1]))] + list(map(tuple, match[:-1])) + for match in response[1]] + else: + matches = [list(map(tuple, match)) + for match in response[1]] + return { + str_if_bytes(response[0]): matches, + str_if_bytes(response[2]): int(response[3]) + } + return str_if_bytes(response) + + def parse_cluster_info(response, **options): response = str_if_bytes(response) return dict(line.split(':') for line in response.splitlines() if line) @@ -673,6 +701,7 @@ class Redis(Commands, object): 'MODULE LIST': lambda r: [pairs_to_dict(m) for m in r], 'OBJECT': parse_object, 'PING': lambda r: str_if_bytes(r) == 'PONG', + 'STRALGO': parse_stralgo, 'PUBSUB NUMSUB': parse_pubsub_numsub, 'RANDOMKEY': lambda r: r and r or None, 'SCAN': parse_scan, diff --git a/redis/commands.py b/redis/commands.py index f940bcc071..a9b90f0222 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1100,6 +1100,53 @@ def setrange(self, name, offset, value): """ return self.execute_command('SETRANGE', name, offset, value) + def stralgo(self, algo, value1, value2, specific_argument='strings', + len=False, idx=False, minmatchlen=None, withmatchlen=False): + """ + Implements complex algorithms that operate on strings. + Right now the only algorithm implemented is the LCS algorithm + (longest common substring). However new algorithms could be + implemented in the future. + + ``algo`` Right now must be LCS + ``value1`` and ``value2`` Can be two strings or two keys + ``specific_argument`` Specifying if the arguments to the algorithm + will be keys or strings. strings is the default. + ``len`` Returns just the len of the match. + ``idx`` Returns the match positions in each string. + ``minmatchlen`` Restrict the list of matches to the ones of a given + minimal length. Can be provided only when ``idx`` set to True. + ``withmatchlen`` Returns the matches with the len of the match. + Can be provided only when ``idx`` set to True. + """ + # check validity + supported_algo = ['LCS'] + if algo not in supported_algo: + raise DataError("The supported algorithms are: %s" + % (', '.join(supported_algo))) + if specific_argument not in ['keys', 'strings']: + raise DataError("specific_argument can be only" + " keys or strings") + if len and idx: + raise DataError("len and idx cannot be provided together.") + + pieces = [algo, specific_argument.upper(), value1, value2] + if len: + pieces.append(b'LEN') + if idx: + pieces.append(b'IDX') + try: + int(minmatchlen) + pieces.extend([b'MINMATCHLEN', minmatchlen]) + except TypeError: + pass + if withmatchlen: + pieces.append(b'WITHMATCHLEN') + + return self.execute_command('STRALGO', *pieces, len=len, idx=idx, + minmatchlen=minmatchlen, + withmatchlen=withmatchlen) + def strlen(self, name): "Return the number of bytes stored in the value of ``name``" return self.execute_command('STRLEN', name) diff --git a/tests/test_commands.py b/tests/test_commands.py index 1d829d6261..4b7957c7dd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1052,6 +1052,49 @@ def test_setrange(self, r): assert r.setrange('a', 6, '12345') == 11 assert r['a'] == b'abcdef12345' + @skip_if_server_version_lt('6.0.0') + def test_stralgo_lcs(self, r): + key1 = 'key1' + key2 = 'key2' + value1 = 'ohmytext' + value2 = 'mynewtext' + res = 'mytext' + # test LCS of strings + assert r.stralgo('LCS', value1, value2) == res + # test using keys + r.mset({key1: value1, key2: value2}) + assert r.stralgo('LCS', key1, key2, specific_argument="keys") == res + # test other labels + assert r.stralgo('LCS', value1, value2, len=True) == len(res) + assert r.stralgo('LCS', value1, value2, idx=True) == \ + { + 'len': len(res), + 'matches': [[(4, 7), (5, 8)], [(2, 3), (0, 1)]] + } + assert r.stralgo('LCS', value1, value2, + idx=True, withmatchlen=True) == \ + { + 'len': len(res), + 'matches': [[4, (4, 7), (5, 8)], [2, (2, 3), (0, 1)]] + } + assert r.stralgo('LCS', value1, value2, + idx=True, minmatchlen=4, withmatchlen=True) == \ + { + 'len': len(res), + 'matches': [[4, (4, 7), (5, 8)]] + } + + @skip_if_server_version_lt('6.0.0') + def test_stralgo_negative(self, r): + with pytest.raises(exceptions.DataError): + r.stralgo('ISSUB', 'value1', 'value2') + with pytest.raises(exceptions.DataError): + r.stralgo('LCS', 'value1', 'value2', len=True, idx=True) + with pytest.raises(exceptions.DataError): + r.stralgo('LCS', 'value1', 'value2', specific_argument="INT") + with pytest.raises(ValueError): + r.stralgo('LCS', 'value1', 'value2', idx=True, minmatchlen="one") + def test_strlen(self, r): r['a'] = 'foo' assert r.strlen('a') == 3 From 41e3f56f65b690ae39ab798a85b42e44ee72a829 Mon Sep 17 00:00:00 2001 From: Ian Bucad Date: Sun, 29 Aug 2021 18:40:29 +1000 Subject: [PATCH 0142/1164] Includes slowlog complexity info if available (#1489) * Return slowlog complexity info if available based on https://github.com/andymccurdy/redis-py/pull/622 * Add tests Copied from https://github.com/andymccurdy/redis-py/pull/622 * address flake E306 * Trigger Build --- redis/client.py | 28 +++++++++++++++++----------- tests/test_commands.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/redis/client.py b/redis/client.py index 3a9a5b6b1b..4a9e33a21e 100755 --- a/redis/client.py +++ b/redis/client.py @@ -397,17 +397,23 @@ def parse_zmscore(response, **options): def parse_slowlog_get(response, **options): space = ' ' if options.get('decode_responses', False) else b' ' - return [{ - 'id': item[0], - 'start_time': int(item[1]), - 'duration': int(item[2]), - 'command': - # Redis Enterprise injects another entry at index [3], which has - # the complexity info (i.e. the value N in case the command has - # an O(N) complexity) instead of the command. - space.join(item[3]) if isinstance(item[3], list) else - space.join(item[4]) - } for item in response] + + def parse_item(item): + result = { + 'id': item[0], + 'start_time': int(item[1]), + 'duration': int(item[2]), + } + # Redis Enterprise injects another entry at index [3], which has + # the complexity info (i.e. the value N in case the command has + # an O(N) complexity) instead of the command. + if isinstance(item[3], list): + result['command'] = space.join(item[3]) + else: + result['complexity'] = item[3] + result['command'] = space.join(item[4]) + return result + return [parse_item(item) for item in response] def parse_stralgo(response, **options): diff --git a/tests/test_commands.py b/tests/test_commands.py index 4b7957c7dd..0e71fcfc41 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -499,6 +499,35 @@ def test_slowlog_get(self, r, slowlog): assert isinstance(slowlog[0]['start_time'], int) assert isinstance(slowlog[0]['duration'], int) + # Mock result if we didn't get slowlog complexity info. + if 'complexity' not in slowlog[0]: + # monkey patch parse_response() + COMPLEXITY_STATEMENT = "Complexity info: N:4712,M:3788" + old_parse_response = r.parse_response + + def parse_response(connection, command_name, **options): + if command_name != 'SLOWLOG GET': + return old_parse_response(connection, + command_name, + **options) + responses = connection.read_response() + for response in responses: + # Complexity info stored as fourth item in list + response.insert(3, COMPLEXITY_STATEMENT) + return r.response_callbacks[command_name](responses, **options) + r.parse_response = parse_response + + # test + slowlog = r.slowlog_get() + assert isinstance(slowlog, list) + commands = [log['command'] for log in slowlog] + assert get_command in commands + idx = commands.index(get_command) + assert slowlog[idx]['complexity'] == COMPLEXITY_STATEMENT + + # tear down monkeypatch + r.parse_response = old_parse_response + def test_slowlog_get_limit(self, r, slowlog): assert r.slowlog_reset() r.get('foo') From efdba1a77a2755c70ba9754aa592dde8c8c50217 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Mon, 30 Aug 2021 09:34:23 +0300 Subject: [PATCH 0143/1164] xgroup_createconsumer (#1553) --- redis/commands.py | 12 ++++++++++++ tests/test_commands.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/redis/commands.py b/redis/commands.py index a9b90f0222..29877dbef9 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1870,6 +1870,18 @@ def xgroup_destroy(self, name, groupname): """ return self.execute_command('XGROUP DESTROY', name, groupname) + def xgroup_createconsumer(self, name, groupname, consumername): + """ + Consumers in a consumer group are auto-created every time a new + consumer name is mentioned by some command. + They can be explicitly created by using this command. + name: name of the stream. + groupname: name of the consumer group. + consumername: name of consumer to create. + """ + return self.execute_command('XGROUP CREATECONSUMER', name, groupname, + consumername) + def xgroup_setid(self, name, groupname, id): """ Set the consumer group last delivered ID to something else. diff --git a/tests/test_commands.py b/tests/test_commands.py index 0e71fcfc41..0c9ca1eb57 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2731,6 +2731,22 @@ def test_xgroup_delconsumer(self, r): # deleting the consumer should return 2 pending messages assert r.xgroup_delconsumer(stream, group, consumer) == 2 + @skip_if_server_version_lt('6.2.0') + def test_xgroup_createconsumer(self, r): + stream = 'stream' + group = 'group' + consumer = 'consumer' + r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {'foo': 'bar'}) + r.xgroup_create(stream, group, 0) + assert r.xgroup_createconsumer(stream, group, consumer) == 1 + + # read all messages from the group + r.xreadgroup(group, consumer, streams={stream: '>'}) + + # deleting the consumer should return 2 pending messages + assert r.xgroup_delconsumer(stream, group, consumer) == 2 + @skip_if_server_version_lt('5.0.0') def test_xgroup_destroy(self, r): stream = 'stream' From de9922ad0fc955668d99748f3ad3be0ccff1f9da Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Wed, 1 Sep 2021 11:44:28 +0300 Subject: [PATCH 0144/1164] Adding EXAT and PXAT (unix time support) support for SET (#1547) * set in unix time * update skip version --- redis/commands.py | 26 ++++++++++++++++++++++---- tests/test_commands.py | 12 ++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 29877dbef9..2d39f5ddf5 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1014,7 +1014,8 @@ def restore(self, name, ttl, value, replace=False, absttl=False): return self.execute_command('RESTORE', *params) def set(self, name, value, - ex=None, px=None, nx=False, xx=False, keepttl=False, get=False): + ex=None, px=None, nx=False, xx=False, keepttl=False, get=False, + exat=None, pxat=None): """ Set the value at key ``name`` to ``value`` @@ -1034,6 +1035,12 @@ def set(self, name, value, ``get`` if True, set the value at key ``name`` to ``value`` and return the old value stored at key, or None when key did not exist. (Available since Redis 6.2) + + ``exat`` sets an expire flag on key ``name`` for ``ex`` seconds, + specified in unix time. + + ``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds, + specified in unix time. """ pieces = [name, value] options = {} @@ -1047,15 +1054,26 @@ def set(self, name, value, if isinstance(px, datetime.timedelta): px = int(px.total_seconds() * 1000) pieces.append(px) + if exat is not None: + pieces.append('EXAT') + if isinstance(exat, datetime.datetime): + s = int(exat.microsecond / 1000000) + exat = int(time.mktime(exat.timetuple())) + s + pieces.append(exat) + if pxat is not None: + pieces.append('PXAT') + if isinstance(pxat, datetime.datetime): + ms = int(pxat.microsecond / 1000) + pxat = int(time.mktime(pxat.timetuple())) * 1000 + ms + pieces.append(pxat) + if keepttl: + pieces.append('KEEPTTL') if nx: pieces.append('NX') if xx: pieces.append('XX') - if keepttl: - pieces.append('KEEPTTL') - if get: pieces.append('GET') options["get"] = True diff --git a/tests/test_commands.py b/tests/test_commands.py index 0c9ca1eb57..2c0137f1e0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1040,6 +1040,18 @@ def test_set_ex_timedelta(self, r): assert r.set('a', '1', ex=expire_at) assert 0 < r.ttl('a') <= 60 + @skip_if_server_version_lt('6.2.0') + def test_set_exat_timedelta(self, r): + expire_at = redis_server_time(r) + datetime.timedelta(seconds=10) + assert r.set('a', '1', exat=expire_at) + assert 0 < r.ttl('a') <= 10 + + @skip_if_server_version_lt('6.2.0') + def test_set_pxat_timedelta(self, r): + expire_at = redis_server_time(r) + datetime.timedelta(seconds=10) + assert r.set('a', '1', pxat=expire_at) + assert 0 < r.ttl('a') <= 10 + @skip_if_server_version_lt('2.6.0') def test_set_multipleoptions(self, r): r['a'] = 'val' From 18c1cc7482d2177809d381e91721f119117849ff Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 11:44:36 +0300 Subject: [PATCH 0145/1164] Support for command count (#1554) Part of #1546 --- redis/client.py | 2 ++ redis/commands.py | 3 +++ tests/test_commands.py | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/redis/client.py b/redis/client.py index 4a9e33a21e..ef375066f1 100755 --- a/redis/client.py +++ b/redis/client.py @@ -685,6 +685,8 @@ class Redis(Commands, object): 'CLUSTER SET-CONFIG-EPOCH': bool_ok, 'CLUSTER SETSLOT': bool_ok, 'CLUSTER SLAVES': parse_cluster_nodes, + 'COMMAND': int, + 'COMMAND COUNT': int, 'CONFIG GET': parse_config_get, 'CONFIG RESETSTAT': bool_ok, 'CONFIG SET': bool_ok, diff --git a/redis/commands.py b/redis/commands.py index 2d39f5ddf5..7066bd8209 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2924,6 +2924,9 @@ def module_list(self): """ return self.execute_command('MODULE LIST') + def command_count(self): + return self.execute_command('COMMAND COUNT') + class Script: "An executable Lua script object returned by ``register_script``" diff --git a/tests/test_commands.py b/tests/test_commands.py index 2c0137f1e0..5a5e8f3b12 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3240,6 +3240,12 @@ def test_module_list(self, r): assert isinstance(r.module_list(), list) assert not r.module_list() + @skip_if_server_version_lt('2.8.13') + def test_command_count(self, r): + res = r.command_count() + assert isinstance(res, int) + assert res >= 100 + class TestBinarySave: From 9f64c56e0f6994df0609a803e613a055ee57a7cd Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 11:44:59 +0300 Subject: [PATCH 0146/1164] bgsave schedule support (#1555) bgsave tests Part of #1546 --- redis/commands.py | 7 +++++-- tests/test_commands.py | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 7066bd8209..e1f7368809 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -268,12 +268,15 @@ def bgrewriteaof(self): "Tell the Redis server to rewrite the AOF file from data in memory." return self.execute_command('BGREWRITEAOF') - def bgsave(self): + def bgsave(self, schedule=True): """ Tell the Redis server to save its data to disk. Unlike save(), this method is asynchronous and returns immediately. """ - return self.execute_command('BGSAVE') + pieces = [] + if schedule: + pieces.append("SCHEDULE") + return self.execute_command('BGSAVE', *pieces) def client_kill(self, address): "Disconnects the client at ``address`` (ip:port)" diff --git a/tests/test_commands.py b/tests/test_commands.py index 5a5e8f3b12..1a34712ffc 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -547,6 +547,11 @@ def test_time(self, r): assert isinstance(t[0], int) assert isinstance(t[1], int) + def test_bgsave(self, r): + assert r.bgsave() + time.sleep(0.3) + assert r.bgsave(True) + # BASIC KEY COMMANDS def test_append(self, r): assert r.append('a', 'a1') == 2 From 879584b7a359cfd7eeb008b41ba9ca9be16e6633 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 12:08:38 +0300 Subject: [PATCH 0147/1164] Support for QUIT (#1557) Part of #1546 --- redis/client.py | 1 + redis/commands.py | 6 ++++++ tests/test_commands.py | 3 +++ 3 files changed, 10 insertions(+) diff --git a/redis/client.py b/redis/client.py index ef375066f1..a4d7f6b43a 100755 --- a/redis/client.py +++ b/redis/client.py @@ -709,6 +709,7 @@ class Redis(Commands, object): 'MODULE LIST': lambda r: [pairs_to_dict(m) for m in r], 'OBJECT': parse_object, 'PING': lambda r: str_if_bytes(r) == 'PONG', + 'QUIT': bool_ok, 'STRALGO': parse_stralgo, 'PUBSUB NUMSUB': parse_pubsub_numsub, 'RANDOMKEY': lambda r: r and r or None, diff --git a/redis/commands.py b/redis/commands.py index e1f7368809..42307799de 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -538,6 +538,12 @@ def ping(self): "Ping the Redis server" return self.execute_command('PING') + def quit(self): + """Ask the server to close the connection. + https://redis.io/commands/quit + """ + return self.execute_command('QUIT') + def save(self): """ Tell the Redis server to save its data to disk, diff --git a/tests/test_commands.py b/tests/test_commands.py index 1a34712ffc..d77a01c29a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -478,6 +478,9 @@ def test_object(self, r): def test_ping(self, r): assert r.ping() + def test_quit(self, r): + assert r.quit() + def test_slowlog_get(self, r, slowlog): assert r.slowlog_reset() unicode_string = chr(3456) + 'abcd' + chr(3421) From 3dc2bf906f634383d33952d36cd78156a6e36e0e Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 13:09:03 +0300 Subject: [PATCH 0148/1164] Adding support for GENPASS bits (#1558) Part of #1546 commands. --- redis/commands.py | 19 ++++++++++++++++--- tests/test_commands.py | 8 ++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 42307799de..895b00a4b5 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -52,9 +52,22 @@ def acl_deluser(self, username): "Delete the ACL for the specified ``username``" return self.execute_command('ACL DELUSER', username) - def acl_genpass(self): - "Generate a random password value" - return self.execute_command('ACL GENPASS') + def acl_genpass(self, bits=None): + """Generate a random password value. + If ``bits`` is supplied then use this number of bits, rounded to + the next multiple of 4. + See: https://redis.io/commands/acl-genpass + """ + pieces = [] + if bits is not None: + try: + b = int(bits) + if b < 0 or b > 4096: + raise ValueError + except ValueError: + raise DataError('genpass optionally accepts a bits argument, ' + 'between 0 and 4096.') + return self.execute_command('ACL GENPASS', *pieces) def acl_getuser(self, username): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index d77a01c29a..6877265f66 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -98,6 +98,14 @@ def test_acl_genpass(self, r): password = r.acl_genpass() assert isinstance(password, str) + with pytest.raises(exceptions.DataError): + r.acl_genpass('value') + r.acl_genpass(-5) + r.acl_genpass(5555) + + r.acl_genpass(555) + assert isinstance(password, str) + @skip_if_server_version_lt(REDIS_6_VERSION) def test_acl_getuser_setuser(self, r, request): username = 'redis-py-user' From e53227cf68c065b4d31f39cdde7c85c5e91dd1bf Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 13:10:56 +0300 Subject: [PATCH 0149/1164] LPUSHX support for list, no API changes (#1559) Part of #1546 --- redis/commands.py | 4 ++-- tests/test_commands.py | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 895b00a4b5..6e816decff 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1318,9 +1318,9 @@ def lpush(self, name, *values): "Push ``values`` onto the head of the list ``name``" return self.execute_command('LPUSH', name, *values) - def lpushx(self, name, value): + def lpushx(self, name, *values): "Push ``value`` onto the head of the list ``name`` if ``name`` exists" - return self.execute_command('LPUSHX', name, value) + return self.execute_command('LPUSHX', name, *values) def lrange(self, name, start, end): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index 6877265f66..169810687f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1271,6 +1271,15 @@ def test_lpushx(self, r): assert r.lpushx('a', '4') == 4 assert r.lrange('a', 0, -1) == [b'4', b'1', b'2', b'3'] + @skip_if_server_version_lt('4.0.0') + def test_lpushx_with_list(self, r): + # now with a list + r.lpush('somekey', 'a') + r.lpush('somekey', 'b') + assert r.lpushx('somekey', 'foo', 'asdasd', 55, 'asdasdas') == 6 + res = r.lrange('somekey', 0, -1) + assert res == [b'asdasdas', b'55', b'asdasd', b'foo', b'b', b'a'] + def test_lrange(self, r): r.rpush('a', '1', '2', '3', '4', '5') assert r.lrange('a', 0, 2) == [b'1', b'2', b'3'] From 0f8d0dcbb3a7f759843f3f89e413f9333d027c98 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Wed, 1 Sep 2021 13:13:42 +0300 Subject: [PATCH 0150/1164] GEOSEARCH and GEOSEARCHSTORE (#1526) * GEOSEARCH and GEOSEARCHSTORE * negative test * change georadius_generic to geosearch_generic * add documentations to the functions * add docstring to the parser * farest --- redis/client.py | 16 ++- redis/commands.py | 130 +++++++++++++++++ tests/test_commands.py | 310 ++++++++++++++++++++++++++++++----------- 3 files changed, 370 insertions(+), 86 deletions(-) diff --git a/redis/client.py b/redis/client.py index a4d7f6b43a..019939a6e8 100755 --- a/redis/client.py +++ b/redis/client.py @@ -472,10 +472,15 @@ def parse_cluster_nodes(response, **options): return dict(_parse_node_line(line) for line in raw_lines) -def parse_georadius_generic(response, **options): +def parse_geosearch_generic(response, **options): + """ + Parse the response of 'GEOSEARCH', GEORADIUS' and 'GEORADIUSBYMEMBER' + commands according to 'withdist', 'withhash' and 'withcoord' labels. + """ if options['store'] or options['store_dist']: - # `store` and `store_diff` cant be combined + # `store` and `store_dist` cant be combined # with other command arguments. + # relevant to 'GEORADIUS' and 'GEORADIUSBYMEMBER' return response if type(response) != list: @@ -483,7 +488,7 @@ def parse_georadius_generic(response, **options): else: response_list = response - if not options['withdist'] and not options['withcoord']\ + if not options['withdist'] and not options['withcoord'] \ and not options['withhash']: # just a bunch of places return response_list @@ -695,8 +700,9 @@ class Redis(Commands, object): 'GEOPOS': lambda r: list(map(lambda ll: (float(ll[0]), float(ll[1])) if ll is not None else None, r)), - 'GEORADIUS': parse_georadius_generic, - 'GEORADIUSBYMEMBER': parse_georadius_generic, + 'GEOSEARCH': parse_geosearch_generic, + 'GEORADIUS': parse_geosearch_generic, + 'GEORADIUSBYMEMBER': parse_geosearch_generic, 'HGETALL': lambda r: r and pairs_to_dict(r) or {}, 'HSCAN': parse_hscan, 'INFO': parse_info, diff --git a/redis/commands.py b/redis/commands.py index 6e816decff..4148f987aa 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2924,6 +2924,136 @@ def _georadiusgeneric(self, command, *args, **kwargs): return self.execute_command(command, *pieces, **kwargs) + def geosearch(self, name, member=None, longitude=None, latitude=None, + unit='m', radius=None, width=None, height=None, sort=None, + count=None, any=False, withcoord=False, + withdist=False, withhash=False): + """ + Return the members of specified key identified by the + ``name`` argument, which are within the borders of the + area specified by a given shape. This command extends the + GEORADIUS command, so in addition to searching within circular + areas, it supports searching within rectangular areas. + This command should be used in place of the deprecated + GEORADIUS and GEORADIUSBYMEMBER commands. + ``member`` Use the position of the given existing + member in the sorted set. Can't be given with ``longitude`` + and ``latitude``. + ``longitude`` and ``latitude`` Use the position given by + this coordinates. Can't be given with ``member`` + ``radius`` Similar to GEORADIUS, search inside circular + area according the given radius. Can't be given with + ``height`` and ``width``. + ``height`` and ``width`` Search inside an axis-aligned + rectangle, determined by the given height and width. + Can't be given with ``radius`` + ``unit`` must be one of the following : m, km, mi, ft. + `m` for meters (the default value), `km` for kilometers, + `mi` for miles and `ft` for feet. + ``sort`` indicates to return the places in a sorted way, + ASC for nearest to farest and DESC for farest to nearest. + ``count`` limit the results to the first count matching items. + ``any`` is set to True, the command will return as soon as + enough matches are found. Can't be provided without ``count`` + ``withdist`` indicates to return the distances of each place. + ``withcoord`` indicates to return the latitude and longitude of + each place. + ``withhash`` indicates to return the geohash string of each place. + """ + + return self._geosearchgeneric('GEOSEARCH', + name, member=member, longitude=longitude, + latitude=latitude, unit=unit, + radius=radius, width=width, + height=height, sort=sort, count=count, + any=any, withcoord=withcoord, + withdist=withdist, withhash=withhash, + store=None, store_dist=None) + + def geosearchstore(self, dest, name, member=None, longitude=None, + latitude=None, unit='m', radius=None, width=None, + height=None, sort=None, count=None, any=False, + storedist=False): + """ + This command is like GEOSEARCH, but stores the result in + ``dest``. By default, it stores the results in the destination + sorted set with their geospatial information. + if ``store_dist`` set to True, the command will stores the + items in a sorted set populated with their distance from the + center of the circle or box, as a floating-point number. + """ + return self._geosearchgeneric('GEOSEARCHSTORE', + dest, name, member=member, + longitude=longitude, latitude=latitude, + unit=unit, radius=radius, width=width, + height=height, sort=sort, count=count, + any=any, withcoord=None, + withdist=None, withhash=None, + store=None, store_dist=storedist) + + def _geosearchgeneric(self, command, *args, **kwargs): + pieces = list(args) + + # FROMMEMBER or FROMLONLAT + if kwargs['member'] is None: + if kwargs['longitude'] is None or kwargs['latitude'] is None: + raise DataError("GEOSEARCH must have member or" + " longitude and latitude") + if kwargs['member']: + if kwargs['longitude'] or kwargs['latitude']: + raise DataError("GEOSEARCH member and longitude or latitude" + " cant be set together") + pieces.extend([b'FROMMEMBER', kwargs['member']]) + if kwargs['longitude'] and kwargs['latitude']: + pieces.extend([b'FROMLONLAT', + kwargs['longitude'], kwargs['latitude']]) + + # BYRADIUS or BYBOX + if kwargs['radius'] is None: + if kwargs['width'] is None or kwargs['height'] is None: + raise DataError("GEOSEARCH must have radius or" + " width and height") + if kwargs['unit'] is None: + raise DataError("GEOSEARCH must have unit") + if kwargs['unit'].lower() not in ('m', 'km', 'mi', 'ft'): + raise DataError("GEOSEARCH invalid unit") + if kwargs['radius']: + if kwargs['width'] or kwargs['height']: + raise DataError("GEOSEARCH radius and width or height" + " cant be set together") + pieces.extend([b'BYRADIUS', kwargs['radius'], kwargs['unit']]) + if kwargs['width'] and kwargs['height']: + pieces.extend([b'BYBOX', + kwargs['width'], kwargs['height'], kwargs['unit']]) + + # sort + if kwargs['sort']: + if kwargs['sort'].upper() == 'ASC': + pieces.append(b'ASC') + elif kwargs['sort'].upper() == 'DESC': + pieces.append(b'DESC') + else: + raise DataError("GEOSEARCH invalid sort") + + # count any + if kwargs['count']: + pieces.extend([b'COUNT', kwargs['count']]) + if kwargs['any']: + pieces.append(b'ANY') + elif kwargs['any']: + raise DataError("GEOSEARCH any can't be provided without count") + + # other properties + for arg_name, byte_repr in ( + ('withdist', b'WITHDIST'), + ('withcoord', b'WITHCOORD'), + ('withhash', b'WITHHASH'), + ('store_dist', b'STOREDIST')): + if kwargs[arg_name]: + pieces.append(byte_repr) + + return self.execute_command(command, *pieces, **kwargs) + # MODULE COMMANDS def module_load(self, path): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index 169810687f..30ad5d59af 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1237,7 +1237,7 @@ def test_linsert(self, r): assert r.lrange('a', 0, -1) == [b'1', b'2', b'2.5', b'3'] assert r.linsert('a', 'before', '2', '1.5') == 5 assert r.lrange('a', 0, -1) == \ - [b'1', b'1.5', b'2', b'2.5', b'3'] + [b'1', b'1.5', b'2', b'2.5', b'3'] def test_llen(self, r): r.rpush('a', '1', '2', '3') @@ -1567,7 +1567,7 @@ def test_zadd(self, r): mapping = {'a1': 1.0, 'a2': 2.0, 'a3': 3.0} r.zadd('a', mapping) assert r.zrange('a', 0, -1, withscores=True) == \ - [(b'a1', 1.0), (b'a2', 2.0), (b'a3', 3.0)] + [(b'a1', 1.0), (b'a2', 2.0), (b'a3', 3.0)] # error cases with pytest.raises(exceptions.DataError): @@ -1585,19 +1585,19 @@ def test_zadd_nx(self, r): assert r.zadd('a', {'a1': 1}) == 1 assert r.zadd('a', {'a1': 99, 'a2': 2}, nx=True) == 1 assert r.zrange('a', 0, -1, withscores=True) == \ - [(b'a1', 1.0), (b'a2', 2.0)] + [(b'a1', 1.0), (b'a2', 2.0)] def test_zadd_xx(self, r): assert r.zadd('a', {'a1': 1}) == 1 assert r.zadd('a', {'a1': 99, 'a2': 2}, xx=True) == 0 assert r.zrange('a', 0, -1, withscores=True) == \ - [(b'a1', 99.0)] + [(b'a1', 99.0)] def test_zadd_ch(self, r): assert r.zadd('a', {'a1': 1}) == 1 assert r.zadd('a', {'a1': 99, 'a2': 2}, ch=True) == 2 assert r.zrange('a', 0, -1, withscores=True) == \ - [(b'a2', 2.0), (b'a1', 99.0)] + [(b'a2', 2.0), (b'a1', 99.0)] def test_zadd_incr(self, r): assert r.zadd('a', {'a1': 1}) == 1 @@ -1694,7 +1694,7 @@ def test_zinterstore_sum(self, r): r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) assert r.zinterstore('d', ['a', 'b', 'c']) == 2 assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a3', 8), (b'a1', 9)] + [(b'a3', 8), (b'a1', 9)] def test_zinterstore_max(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) @@ -1702,7 +1702,7 @@ def test_zinterstore_max(self, r): r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) assert r.zinterstore('d', ['a', 'b', 'c'], aggregate='MAX') == 2 assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a3', 5), (b'a1', 6)] + [(b'a3', 5), (b'a1', 6)] def test_zinterstore_min(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) @@ -1710,7 +1710,7 @@ def test_zinterstore_min(self, r): r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) assert r.zinterstore('d', ['a', 'b', 'c'], aggregate='MIN') == 2 assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a1', 1), (b'a3', 3)] + [(b'a1', 1), (b'a3', 3)] def test_zinterstore_with_weight(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) @@ -1718,7 +1718,7 @@ def test_zinterstore_with_weight(self, r): r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) assert r.zinterstore('d', {'a': 1, 'b': 2, 'c': 3}) == 2 assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a3', 20), (b'a1', 23)] + [(b'a3', 20), (b'a1', 23)] @skip_if_server_version_lt('4.9.0') def test_zpopmax(self, r): @@ -1727,7 +1727,7 @@ def test_zpopmax(self, r): # with count assert r.zpopmax('a', count=2) == \ - [(b'a2', 2), (b'a1', 1)] + [(b'a2', 2), (b'a1', 1)] @skip_if_server_version_lt('4.9.0') def test_zpopmin(self, r): @@ -1736,7 +1736,7 @@ def test_zpopmin(self, r): # with count assert r.zpopmin('a', count=2) == \ - [(b'a2', 2), (b'a3', 3)] + [(b'a2', 2), (b'a3', 3)] @skip_if_server_version_lt('6.2.0') def test_zrandemember(self, r): @@ -1781,13 +1781,13 @@ def test_zrange(self, r): # withscores assert r.zrange('a', 0, 1, withscores=True) == \ - [(b'a1', 1.0), (b'a2', 2.0)] + [(b'a1', 1.0), (b'a2', 2.0)] assert r.zrange('a', 1, 2, withscores=True) == \ - [(b'a2', 2.0), (b'a3', 3.0)] + [(b'a2', 2.0), (b'a3', 3.0)] # custom score function assert r.zrange('a', 0, 1, withscores=True, score_cast_func=int) == \ - [(b'a1', 1), (b'a2', 2)] + [(b'a1', 1), (b'a2', 2)] @skip_if_server_version_lt('6.2.0') def test_zrangestore(self, r): @@ -1805,7 +1805,7 @@ def test_zrangebylex(self, r): assert r.zrangebylex('a', '-', '[c') == [b'a', b'b', b'c'] assert r.zrangebylex('a', '-', '(c') == [b'a', b'b'] assert r.zrangebylex('a', '[aaa', '(g') == \ - [b'b', b'c', b'd', b'e', b'f'] + [b'b', b'c', b'd', b'e', b'f'] assert r.zrangebylex('a', '[f', '+') == [b'f', b'g'] assert r.zrangebylex('a', '-', '+', start=3, num=2) == [b'd', b'e'] @@ -1815,10 +1815,10 @@ def test_zrevrangebylex(self, r): assert r.zrevrangebylex('a', '[c', '-') == [b'c', b'b', b'a'] assert r.zrevrangebylex('a', '(c', '-') == [b'b', b'a'] assert r.zrevrangebylex('a', '(g', '[aaa') == \ - [b'f', b'e', b'd', b'c', b'b'] + [b'f', b'e', b'd', b'c', b'b'] assert r.zrevrangebylex('a', '+', '[f') == [b'g', b'f'] assert r.zrevrangebylex('a', '+', '-', start=3, num=2) == \ - [b'd', b'c'] + [b'd', b'c'] def test_zrangebyscore(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) @@ -1826,16 +1826,16 @@ def test_zrangebyscore(self, r): # slicing with start/num assert r.zrangebyscore('a', 2, 4, start=1, num=2) == \ - [b'a3', b'a4'] + [b'a3', b'a4'] # withscores assert r.zrangebyscore('a', 2, 4, withscores=True) == \ - [(b'a2', 2.0), (b'a3', 3.0), (b'a4', 4.0)] + [(b'a2', 2.0), (b'a3', 3.0), (b'a4', 4.0)] # custom score function assert r.zrangebyscore('a', 2, 4, withscores=True, score_cast_func=int) == \ - [(b'a2', 2), (b'a3', 3), (b'a4', 4)] + [(b'a2', 2), (b'a3', 3), (b'a4', 4)] def test_zrank(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) @@ -1884,14 +1884,14 @@ def test_zrevrange(self, r): # withscores assert r.zrevrange('a', 0, 1, withscores=True) == \ - [(b'a3', 3.0), (b'a2', 2.0)] + [(b'a3', 3.0), (b'a2', 2.0)] assert r.zrevrange('a', 1, 2, withscores=True) == \ - [(b'a2', 2.0), (b'a1', 1.0)] + [(b'a2', 2.0), (b'a1', 1.0)] # custom score function assert r.zrevrange('a', 0, 1, withscores=True, score_cast_func=int) == \ - [(b'a3', 3.0), (b'a2', 2.0)] + [(b'a3', 3.0), (b'a2', 2.0)] def test_zrevrangebyscore(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) @@ -1899,16 +1899,16 @@ def test_zrevrangebyscore(self, r): # slicing with start/num assert r.zrevrangebyscore('a', 4, 2, start=1, num=2) == \ - [b'a3', b'a2'] + [b'a3', b'a2'] # withscores assert r.zrevrangebyscore('a', 4, 2, withscores=True) == \ - [(b'a4', 4.0), (b'a3', 3.0), (b'a2', 2.0)] + [(b'a4', 4.0), (b'a3', 3.0), (b'a2', 2.0)] # custom score function assert r.zrevrangebyscore('a', 4, 2, withscores=True, score_cast_func=int) == \ - [(b'a4', 4), (b'a3', 3), (b'a2', 2)] + [(b'a4', 4), (b'a3', 3), (b'a2', 2)] def test_zrevrank(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) @@ -1948,7 +1948,7 @@ def test_zunionstore_sum(self, r): r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) assert r.zunionstore('d', ['a', 'b', 'c']) == 4 assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] def test_zunionstore_max(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) @@ -1956,7 +1956,7 @@ def test_zunionstore_max(self, r): r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) assert r.zunionstore('d', ['a', 'b', 'c'], aggregate='MAX') == 4 assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] def test_zunionstore_min(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) @@ -1964,7 +1964,7 @@ def test_zunionstore_min(self, r): r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) assert r.zunionstore('d', ['a', 'b', 'c'], aggregate='MIN') == 4 assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a1', 1), (b'a2', 2), (b'a3', 3), (b'a4', 4)] + [(b'a1', 1), (b'a2', 2), (b'a3', 3), (b'a4', 4)] def test_zunionstore_with_weight(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) @@ -1972,7 +1972,7 @@ def test_zunionstore_with_weight(self, r): r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) assert r.zunionstore('d', {'a': 1, 'b': 2, 'c': 3}) == 4 assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] + [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] @skip_if_server_version_lt('6.1.240') def test_zmscore(self, r): @@ -2153,7 +2153,7 @@ def test_sort_get_multi(self, r): r['user:3'] = 'u3' r.rpush('a', '2', '3', '1') assert r.sort('a', get=('user:*', '#')) == \ - [b'u1', b'1', b'u2', b'2', b'u3', b'3'] + [b'u1', b'1', b'u2', b'2', b'u3', b'3'] def test_sort_get_groups_two(self, r): r['user:1'] = 'u1' @@ -2161,7 +2161,7 @@ def test_sort_get_groups_two(self, r): r['user:3'] = 'u3' r.rpush('a', '2', '3', '1') assert r.sort('a', get=('user:*', '#'), groups=True) == \ - [(b'u1', b'1'), (b'u2', b'2'), (b'u3', b'3')] + [(b'u1', b'1'), (b'u2', b'2'), (b'u3', b'3')] def test_sort_groups_string_get(self, r): r['user:1'] = 'u1' @@ -2196,11 +2196,11 @@ def test_sort_groups_three_gets(self, r): r['door:3'] = 'd3' r.rpush('a', '2', '3', '1') assert r.sort('a', get=('user:*', 'door:*', '#'), groups=True) == \ - [ - (b'u1', b'd1', b'1'), - (b'u2', b'd2', b'2'), - (b'u3', b'd3', b'3') - ] + [ + (b'u1', b'd1', b'1'), + (b'u2', b'd2', b'2'), + (b'u3', b'd3', b'3') + ] def test_sort_desc(self, r): r.rpush('a', '2', '3', '1') @@ -2209,7 +2209,7 @@ def test_sort_desc(self, r): def test_sort_alpha(self, r): r.rpush('a', 'e', 'c', 'b', 'd', 'a') assert r.sort('a', alpha=True) == \ - [b'a', b'b', b'c', b'd', b'e'] + [b'a', b'b', b'c', b'd', b'e'] def test_sort_store(self, r): r.rpush('a', '2', '3', '1') @@ -2241,7 +2241,7 @@ def test_sort_all_options(self, r): store='sorted') assert num == 4 assert r.lrange('sorted', 0, 10) == \ - [b'vodka', b'milk', b'gin', b'apple juice'] + [b'vodka', b'milk', b'gin', b'apple juice'] def test_sort_issue_924(self, r): # Tests for issue https://github.com/andymccurdy/redis-py/issues/924 @@ -2314,7 +2314,7 @@ def test_readonly(self, mock_cluster_resp_ok): # GEO COMMANDS @skip_if_server_version_lt('3.2.0') def test_geoadd(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') assert r.geoadd('barcelona', *values) == 2 @@ -2327,7 +2327,7 @@ def test_geoadd_invalid_params(self, r): @skip_if_server_version_lt('3.2.0') def test_geodist(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') assert r.geoadd('barcelona', *values) == 2 @@ -2335,7 +2335,7 @@ def test_geodist(self, r): @skip_if_server_version_lt('3.2.0') def test_geodist_units(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) @@ -2354,24 +2354,24 @@ def test_geodist_invalid_units(self, r): @skip_if_server_version_lt('3.2.0') def test_geohash(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) - assert r.geohash('barcelona', 'place1', 'place2', 'place3') ==\ - ['sp3e9yg3kd0', 'sp3e9cbc3t0', None] + assert r.geohash('barcelona', 'place1', 'place2', 'place3') == \ + ['sp3e9yg3kd0', 'sp3e9cbc3t0', None] @skip_unless_arch_bits(64) @skip_if_server_version_lt('3.2.0') def test_geopos(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) # redis uses 52 bits precision, hereby small errors may be introduced. - assert r.geopos('barcelona', 'place1', 'place2') ==\ - [(2.19093829393386841, 41.43379028184083523), - (2.18737632036209106, 41.40634178640635099)] + assert r.geopos('barcelona', 'place1', 'place2') == \ + [(2.19093829393386841, 41.43379028184083523), + (2.18737632036209106, 41.40634178640635099)] @skip_if_server_version_lt('4.0.0') def test_geopos_no_value(self, r): @@ -2382,9 +2382,157 @@ def test_geopos_no_value(self, r): def test_old_geopos_no_value(self, r): assert r.geopos('barcelona', 'place1', 'place2') == [] + @skip_if_server_version_lt('6.2.0') + def test_geosearch(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, b'\x80place2') + \ + (2.583333, 41.316667, 'place3') + r.geoadd('barcelona', *values) + assert r.geosearch('barcelona', longitude=2.191, + latitude=41.433, radius=1000) == [b'place1'] + assert r.geosearch('barcelona', longitude=2.187, + latitude=41.406, radius=1000) == [b'\x80place2'] + assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, + height=1000, width=1000) == [b'place1'] + assert r.geosearch('barcelona', member='place3', radius=100, + unit='km') == [b'\x80place2', b'place1', b'place3'] + # test count + assert r.geosearch('barcelona', member='place3', radius=100, + unit='km', count=2) == [b'place3', b'\x80place2'] + assert r.geosearch('barcelona', member='place3', radius=100, + unit='km', count=1, any=1)[0] \ + in [b'place1', b'place3', b'\x80place2'] + + @skip_unless_arch_bits(64) + @skip_if_server_version_lt('6.2.0') + def test_geosearch_member(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, b'\x80place2') + + r.geoadd('barcelona', *values) + assert r.geosearch('barcelona', member='place1', radius=4000) == \ + [b'\x80place2', b'place1'] + assert r.geosearch('barcelona', member='place1', radius=10) == \ + [b'place1'] + + assert r.geosearch('barcelona', member='place1', radius=4000, + withdist=True, + withcoord=True, + withhash=True) == \ + [[b'\x80place2', 3067.4157, 3471609625421029, + (2.187376320362091, 41.40634178640635)], + [b'place1', 0.0, 3471609698139488, + (2.1909382939338684, 41.433790281840835)]] + + @skip_if_server_version_lt('6.2.0') + def test_geosearch_sort(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + r.geoadd('barcelona', *values) + assert r.geosearch('barcelona', longitude=2.191, + latitude=41.433, radius=3000, sort='ASC') == \ + [b'place1', b'place2'] + assert r.geosearch('barcelona', longitude=2.191, + latitude=41.433, radius=3000, sort='DESC') == \ + [b'place2', b'place1'] + + @skip_unless_arch_bits(64) + @skip_if_server_version_lt('6.2.0') + def test_geosearch_with(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + r.geoadd('barcelona', *values) + + # test a bunch of combinations to test the parse response + # function. + assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, + radius=1, unit='km', withdist=True, + withcoord=True, withhash=True) == \ + [[b'place1', 0.0881, 3471609698139488, + (2.19093829393386841, 41.43379028184083523)]] + assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, + radius=1, unit='km', + withdist=True, withcoord=True) == \ + [[b'place1', 0.0881, + (2.19093829393386841, 41.43379028184083523)]] + assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, + radius=1, unit='km', + withhash=True, withcoord=True) == \ + [[b'place1', 3471609698139488, + (2.19093829393386841, 41.43379028184083523)]] + # test no values. + assert r.geosearch('barcelona', longitude=2, latitude=1, + radius=1, unit='km', withdist=True, + withcoord=True, withhash=True) == [] + + @skip_if_server_version_lt('6.2.0') + def test_geosearch_negative(self, r): + # not specifying member nor longitude and latitude + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona') + # specifying member and longitude and latitude + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', + member="Paris", longitude=2, latitude=1) + # specifying one of longitude and latitude + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', longitude=2) + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', latitude=2) + + # not specifying radius nor width and height + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', member="Paris") + # specifying radius and width and height + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', member="Paris", + radius=3, width=2, height=1) + # specifying one of width and height + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', member="Paris", width=2) + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', member="Paris", height=2) + + # invalid sort + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', + member="Paris", width=2, height=2, sort="wrong") + + # invalid unit + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', + member="Paris", width=2, height=2, unit="miles") + + # use any without count + with pytest.raises(exceptions.DataError): + assert r.geosearch('barcelona', member='place3', radius=100, any=1) + + @skip_if_server_version_lt('6.2.0') + def test_geosearchstore(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + + r.geoadd('barcelona', *values) + r.geosearchstore('places_barcelona', 'barcelona', + longitude=2.191, latitude=41.433, radius=1000) + assert r.zrange('places_barcelona', 0, -1) == [b'place1'] + + @skip_unless_arch_bits(64) + @skip_if_server_version_lt('6.2.0') + def test_geosearchstore_dist(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + + r.geoadd('barcelona', *values) + r.geosearchstore('places_barcelona', 'barcelona', + longitude=2.191, latitude=41.433, + radius=1000, storedist=True) + # instead of save the geo score, the distance is saved. + assert r.zscore('places_barcelona', 'place1') == 88.05060698409301 + @skip_if_server_version_lt('3.2.0') def test_georadius(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, b'\x80place2') r.geoadd('barcelona', *values) @@ -2393,7 +2541,7 @@ def test_georadius(self, r): @skip_if_server_version_lt('3.2.0') def test_georadius_no_values(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) @@ -2401,17 +2549,17 @@ def test_georadius_no_values(self, r): @skip_if_server_version_lt('3.2.0') def test_georadius_units(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) - assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km') ==\ - [b'place1'] + assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km') == \ + [b'place1'] @skip_unless_arch_bits(64) @skip_if_server_version_lt('3.2.0') def test_georadius_with(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) @@ -2419,19 +2567,19 @@ def test_georadius_with(self, r): # test a bunch of combinations to test the parse response # function. assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km', - withdist=True, withcoord=True, withhash=True) ==\ - [[b'place1', 0.0881, 3471609698139488, - (2.19093829393386841, 41.43379028184083523)]] + withdist=True, withcoord=True, withhash=True) == \ + [[b'place1', 0.0881, 3471609698139488, + (2.19093829393386841, 41.43379028184083523)]] assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km', - withdist=True, withcoord=True) ==\ - [[b'place1', 0.0881, - (2.19093829393386841, 41.43379028184083523)]] + withdist=True, withcoord=True) == \ + [[b'place1', 0.0881, + (2.19093829393386841, 41.43379028184083523)]] assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km', - withhash=True, withcoord=True) ==\ - [[b'place1', 3471609698139488, - (2.19093829393386841, 41.43379028184083523)]] + withhash=True, withcoord=True) == \ + [[b'place1', 3471609698139488, + (2.19093829393386841, 41.43379028184083523)]] # test no values. assert r.georadius('barcelona', 2, 1, 1, unit='km', @@ -2439,27 +2587,27 @@ def test_georadius_with(self, r): @skip_if_server_version_lt('3.2.0') def test_georadius_count(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) - assert r.georadius('barcelona', 2.191, 41.433, 3000, count=1) ==\ - [b'place1'] + assert r.georadius('barcelona', 2.191, 41.433, 3000, count=1) == \ + [b'place1'] @skip_if_server_version_lt('3.2.0') def test_georadius_sort(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) - assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='ASC') ==\ - [b'place1', b'place2'] - assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='DESC') ==\ - [b'place2', b'place1'] + assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='ASC') == \ + [b'place1', b'place2'] + assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='DESC') == \ + [b'place2', b'place1'] @skip_if_server_version_lt('3.2.0') def test_georadius_store(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) @@ -2469,7 +2617,7 @@ def test_georadius_store(self, r): @skip_unless_arch_bits(64) @skip_if_server_version_lt('3.2.0') def test_georadius_store_dist(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') r.geoadd('barcelona', *values) @@ -2481,20 +2629,20 @@ def test_georadius_store_dist(self, r): @skip_unless_arch_bits(64) @skip_if_server_version_lt('3.2.0') def test_georadiusmember(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') +\ + values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, b'\x80place2') r.geoadd('barcelona', *values) - assert r.georadiusbymember('barcelona', 'place1', 4000) ==\ - [b'\x80place2', b'place1'] + assert r.georadiusbymember('barcelona', 'place1', 4000) == \ + [b'\x80place2', b'place1'] assert r.georadiusbymember('barcelona', 'place1', 10) == [b'place1'] assert r.georadiusbymember('barcelona', 'place1', 4000, withdist=True, withcoord=True, - withhash=True) ==\ - [[b'\x80place2', 3067.4157, 3471609625421029, - (2.187376320362091, 41.40634178640635)], - [b'place1', 0.0, 3471609698139488, + withhash=True) == \ + [[b'\x80place2', 3067.4157, 3471609625421029, + (2.187376320362091, 41.40634178640635)], + [b'place1', 0.0, 3471609698139488, (2.1909382939338684, 41.433790281840835)]] @skip_if_server_version_lt('5.0.0') From 51516cbd16b538584b7ea8c6a0cdbc76cda3d90a Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 13:42:37 +0300 Subject: [PATCH 0151/1164] Support for CLIENT TRACKINFO (#1560) Part of #1546 --- redis/client.py | 1 + redis/commands.py | 7 +++++++ tests/test_commands.py | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/redis/client.py b/redis/client.py index 019939a6e8..6eee3911ec 100755 --- a/redis/client.py +++ b/redis/client.py @@ -674,6 +674,7 @@ class Redis(Commands, object): 'CLIENT SETNAME': bool_ok, 'CLIENT UNBLOCK': lambda r: r and int(r) == 1 or False, 'CLIENT PAUSE': bool_ok, + 'CLIENT TRACKINGINFO': lambda r: list(map(str_if_bytes, r)), 'CLUSTER ADDSLOTS': bool_ok, 'CLUSTER COUNT-FAILURE-REPORTS': lambda x: int(x), 'CLUSTER COUNTKEYSINSLOT': lambda x: int(x), diff --git a/redis/commands.py b/redis/commands.py index 4148f987aa..27276bc220 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -369,6 +369,13 @@ def client_id(self): "Returns the current connection id" return self.execute_command('CLIENT ID') + def client_trackinginfo(self): + """Returns the information about the current client connection's + use of the server assisted client side cache. + See https://redis.io/commands/client-trackinginfo + """ + return self.execute_command('CLIENT TRACKINGINFO') + def client_setname(self, name): "Sets the current connection name" return self.execute_command('CLIENT SETNAME', name) diff --git a/tests/test_commands.py b/tests/test_commands.py index 30ad5d59af..fd77cc873a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -315,6 +315,12 @@ def test_client_list_client_id(self, r): def test_client_id(self, r): assert r.client_id() > 0 + @skip_if_server_version_lt('6.2.0') + def test_client_trackinginfo(self, r): + res = r.client_trackinginfo() + assert len(res) > 2 + assert 'prefixes' in res + @skip_if_server_version_lt('5.0.0') def test_client_unblock(self, r): myid = r.client_id() From da6e3524622c9e9862ac57de60d9f188e106b29d Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 14:57:07 +0300 Subject: [PATCH 0152/1164] Adding DELUSER list of users support (#1562) Adding support for ACL help Part of #1546 --- redis/client.py | 1 + redis/commands.py | 10 ++++++++-- tests/test_commands.py | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/redis/client.py b/redis/client.py index 6eee3911ec..8979280a56 100755 --- a/redis/client.py +++ b/redis/client.py @@ -659,6 +659,7 @@ class Redis(Commands, object): 'ACL DELUSER': int, 'ACL GENPASS': str_if_bytes, 'ACL GETUSER': parse_acl_getuser, + 'ACL HELP': lambda r: list(map(str_if_bytes, r)), 'ACL LIST': lambda r: list(map(str_if_bytes, r)), 'ACL LOAD': bool_ok, 'ACL LOG': parse_acl_log, diff --git a/redis/commands.py b/redis/commands.py index 27276bc220..c49f04388a 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -48,9 +48,9 @@ def acl_cat(self, category=None): pieces = [category] if category else [] return self.execute_command('ACL CAT', *pieces) - def acl_deluser(self, username): + def acl_deluser(self, *username): "Delete the ACL for the specified ``username``" - return self.execute_command('ACL DELUSER', username) + return self.execute_command('ACL DELUSER', *username) def acl_genpass(self, bits=None): """Generate a random password value. @@ -77,6 +77,12 @@ def acl_getuser(self, username): """ return self.execute_command('ACL GETUSER', username) + def acl_help(self): + """The ACL HELP command returns helpful text describing + the different subcommands. + """ + return self.execute_command('ACL HELP') + def acl_list(self): "Return a list of all ACLs on the server" return self.execute_command('ACL LIST') diff --git a/tests/test_commands.py b/tests/test_commands.py index fd77cc873a..fde1c0119c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -93,6 +93,17 @@ def teardown(): assert r.acl_setuser(username, enabled=False, reset=True) assert r.acl_deluser(username) == 1 + # now, a group of users + users = ['bogususer_%d' % r for r in range(0, 5)] + for u in users: + r.acl_setuser(u, enabled=False, reset=True) + assert r.acl_deluser(*users) > 1 + assert r.acl_getuser(users[0]) is None + assert r.acl_getuser(users[1]) is None + assert r.acl_getuser(users[2]) is None + assert r.acl_getuser(users[3]) is None + assert r.acl_getuser(users[4]) is None + @skip_if_server_version_lt(REDIS_6_VERSION) def test_acl_genpass(self, r): password = r.acl_genpass() @@ -193,6 +204,12 @@ def teardown(): hashed_passwords=['-' + hashed_password]) assert len(r.acl_getuser(username)['passwords']) == 1 + @skip_if_server_version_lt(REDIS_6_VERSION) + def test_acl_help(self, r): + res = r.acl_help() + assert isinstance(res, list) + assert len(res) != 0 + @skip_if_server_version_lt(REDIS_6_VERSION) def test_acl_list(self, r, request): username = 'redis-py-user' From b6ecd0d6b7e6848dd085c3644ed31598fb1c9f83 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 17:08:59 +0300 Subject: [PATCH 0153/1164] fixing timing issues in set pxat test (#1566) closes #1561 --- tests/test_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index fde1c0119c..24c528d2d2 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1087,9 +1087,9 @@ def test_set_exat_timedelta(self, r): @skip_if_server_version_lt('6.2.0') def test_set_pxat_timedelta(self, r): - expire_at = redis_server_time(r) + datetime.timedelta(seconds=10) + expire_at = redis_server_time(r) + datetime.timedelta(seconds=50) assert r.set('a', '1', pxat=expire_at) - assert 0 < r.ttl('a') <= 10 + assert 0 < r.ttl('a') <= 100 @skip_if_server_version_lt('2.6.0') def test_set_multipleoptions(self, r): From febede19423c95515a7548cd73aa1a90c639ba1f Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 17:09:41 +0300 Subject: [PATCH 0154/1164] Pipeline DISCARD support (#1565) closes #1539 Part of #1546 --- redis/client.py | 6 ++++++ tests/test_pipeline.py | 28 +++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 8979280a56..7a022a0671 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1810,6 +1810,12 @@ def execute(self, raise_on_error=True): finally: self.reset() + def discard(self): + """Flushes all previously queued commands + See: https://redis.io/commands/DISCARD + """ + self.execute_command("DISCARD") + def watch(self, *names): "Watches the values at keys ``names``" if self.explicit_transaction: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9bc4a9f4d9..08bd40bacd 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,7 +1,7 @@ import pytest import redis -from .conftest import wait_for_command +from .conftest import wait_for_command, skip_if_server_version_lt class TestPipeline: @@ -353,3 +353,29 @@ def test_pipeline_with_bitfield(self, r): assert pipe == pipe2 assert response == [True, [0, 0, 15, 15, 14], b'1'] + + @skip_if_server_version_lt('2.0.0') + def test_pipeline_discard(self, r): + + # empty pipeline should raise an error + with r.pipeline() as pipe: + pipe.set('key', 'someval') + pipe.discard() + with pytest.raises(redis.exceptions.ResponseError): + pipe.execute() + + # setting a pipeline and discarding should do the same + with r.pipeline() as pipe: + pipe.set('key', 'someval') + pipe.set('someotherkey', 'val') + response = pipe.execute() + pipe.set('key', 'another value!') + pipe.discard() + pipe.set('key', 'another vae!') + with pytest.raises(redis.exceptions.ResponseError): + pipe.execute() + + pipe.set('foo', 'bar') + response = pipe.execute() + assert response[0] + assert r.get('foo') == b'bar' From 42a050c6c0d120104c7433e825e7a798ba411e55 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 17:14:55 +0300 Subject: [PATCH 0155/1164] CLIENT LIST fix to allow multiple client_ids (#1563) * CLIENT LIST fix to allow multiple client_ids Support for CLIENT KILL with the USER filter Part of #1546 * test fix --- redis/commands.py | 15 ++++++++++----- tests/test_commands.py | 25 ++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index c49f04388a..7f7e00f2c8 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -302,7 +302,7 @@ def client_kill(self, address): return self.execute_command('CLIENT KILL', address) def client_kill_filter(self, _id=None, _type=None, addr=None, - skipme=None, laddr=None): + skipme=None, laddr=None, user=None): """ Disconnects client(s) using a variety of filter options :param id: Kills a client by its unique ID field @@ -310,7 +310,8 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, 'master', 'slave' or 'pubsub' :param addr: Kills a client by its 'address:port' :param skipme: If True, then the client calling the command - :param laddr: Kills a cient by its 'local (bind) address:port' + :param laddr: Kills a client by its 'local (bind) address:port' + :param user: Kills a client for a specific user name will not get killed even if it is identified by one of the filter options. If skipme is not provided, the server defaults to skipme=True """ @@ -334,6 +335,8 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, args.extend((b'ADDR', addr)) if laddr is not None: args.extend((b'LADDR', laddr)) + if user is not None: + args.extend((b'USER', user)) if not args: raise DataError("CLIENT KILL ... ... " " must specify at least one filter") @@ -346,7 +349,7 @@ def client_info(self): """ return self.execute_command('CLIENT INFO') - def client_list(self, _type=None, client_id=None): + def client_list(self, _type=None, client_id=[]): """ Returns a list of currently connected clients. If type of client specified, only that type will be returned. @@ -362,9 +365,11 @@ def client_list(self, _type=None, client_id=None): client_types,)) args.append(b'TYPE') args.append(_type) - if client_id is not None: + if not isinstance(client_id, list): + raise DataError("client_id must be a list") + if client_id != []: args.append(b"ID") - args.append(client_id) + args.append(' '.join(client_id)) return self.execute_command('CLIENT LIST', *args) def client_getname(self): diff --git a/tests/test_commands.py b/tests/test_commands.py index 24c528d2d2..254aba5f59 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -321,13 +321,19 @@ def test_client_list_type(self, r): assert isinstance(clients, list) @skip_if_server_version_lt('6.2.0') - def test_client_list_client_id(self, r): + def test_client_list_client_id(self, r, request): clients = r.client_list() - client_id = clients[0]['id'] - clients = r.client_list(client_id=client_id) + clients = r.client_list(client_id=[clients[0]['id']]) assert len(clients) == 1 assert 'addr' in clients[0] + # testing multiple client ids + _get_client(redis.Redis, request, flushdb=False) + _get_client(redis.Redis, request, flushdb=False) + _get_client(redis.Redis, request, flushdb=False) + clients_listed = r.client_list(client_id=clients[:-1]) + assert len(clients_listed) > 1 + @skip_if_server_version_lt('5.0.0') def test_client_id(self, r): assert r.client_id() > 0 @@ -448,6 +454,19 @@ def test_client_kill_filter_by_laddr(self, r, r2): client_2_addr = clients_by_name['redis-py-c2'].get('laddr') assert r.client_kill_filter(laddr=client_2_addr) + @skip_if_server_version_lt('2.8.12') + def test_client_kill_filter_by_user(self, r, request): + killuser = 'user_to_kill' + r.acl_setuser(killuser, enabled=True, reset=True, + commands=['+get', '+set', '+select'], + keys=['cache:*'], nopass=True) + _get_client(redis.Redis, request, flushdb=False, username=killuser) + r.client_kill_filter(user=killuser) + clients = r.client_list() + for c in clients: + assert c['user'] != killuser + r.acl_deluser(killuser) + @skip_if_server_version_lt('2.9.50') def test_client_pause(self, r): assert r.client_pause(1) From e9837c1d6360d27fac0d8fed6384fd9b2b568b5c Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 1 Sep 2021 17:31:55 +0300 Subject: [PATCH 0156/1164] Support for SCRIPT FLUSH with SYNC/ASYNC (#1567) Part of #1546 --- redis/commands.py | 14 +++++++++++--- tests/test_scripting.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 7f7e00f2c8..5f1f57b305 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2786,9 +2786,17 @@ def script_exists(self, *args): """ return self.execute_command('SCRIPT EXISTS', *args) - def script_flush(self): - "Flush all scripts from the script cache" - return self.execute_command('SCRIPT FLUSH') + def script_flush(self, sync_type="SYNC"): + """Flush all scripts from the script cache. + ``sync_type`` is by default SYNC (synchronous) but it can also be + ASYNC. + See: https://redis.io/commands/script-flush + """ + if sync_type not in ["SYNC", "ASYNC"]: + raise DataError("SCRIPT FLUSH defaults to SYNC or" + "accepts SYNC/ASYNC") + pieces = [sync_type] + return self.execute_command('SCRIPT FLUSH', *pieces) def script_kill(self): "Kill the currently executing Lua script" diff --git a/tests/test_scripting.py b/tests/test_scripting.py index 02c0f171d1..cc67e26678 100644 --- a/tests/test_scripting.py +++ b/tests/test_scripting.py @@ -30,6 +30,24 @@ def test_eval(self, r): # 2 * 3 == 6 assert r.eval(multiply_script, 1, 'a', 3) == 6 + def test_script_flush(self, r): + r.set('a', 2) + r.script_load(multiply_script) + r.script_flush('ASYNC') + + r.set('a', 2) + r.script_load(multiply_script) + r.script_flush('SYNC') + + r.set('a', 2) + r.script_load(multiply_script) + r.script_flush() + + with pytest.raises(exceptions.DataError): + r.set('a', 2) + r.script_load(multiply_script) + r.script_flush("NOTREAL") + def test_evalsha(self, r): r.set('a', 2) sha = r.script_load(multiply_script) From 9ed5cd7808789f791fdc7ee368bd268307ac9847 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 5 Sep 2021 11:00:02 +0300 Subject: [PATCH 0157/1164] Updating CHANGES with the latest improvements. (#1569) Thanks everyone for all your contributions! --- CHANGES | 55 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/CHANGES b/CHANGES index 9a1df64432..057423dac1 100644 --- a/CHANGES +++ b/CHANGES @@ -4,14 +4,43 @@ urllib.parse.unquote. Prior versions of redis-py supported this by specifying the ``decode_components`` flag to the ``from_url`` functions. This is now done by default and cannot be disabled. #589 - * Provide a development and testing environment via docker. Thanks - @abrookins. #1365 - * Added support for the LPOS command available in Redis 6.0.6. Thanks - @aparcar #1353/#1354 - * Added support for the ACL LOG command available in Redis 6. Thanks - @2014BDuck. #1307 - * Added support for ABSTTL option of the RESTORE command available in - Redis 5.0. Thanks @charettes. #1423 + * POTENTIALLY INCOMPATIBLE: Redis commands were moved into a mixin + (see commands.py). Anyone importing ``redis.client`` to access commands + directly should import ``redis.commands``. + * Added support for ASYNC to SCRIPT FLUSH available in Redis 6.2.0. + Thanks @chayim. #1567 + * Added CLIENT LIST fix to support multiple client ids available in + Redis 2.8.12. Thanks @chayim #1563. + * Added DISCARD support for pipelines available in Redis 2.0.0. + Thanks @chayim #1565. + * Added ACL DELUSER support for deleting lists of users available in + Redis 6.2.0. Thanks @chayim. #1562 + * Added CLIENT TRACKINFO support available in Redis 6.2.0. + Thanks @chayim. #1560 + * Added GEOSEARCH and GEOSEARCHSTORE support available in Redis 6.2.0. + Thanks @AvitalFine Redis. #1526 + * Added LPUSHX support for lists available in Redis 4.0.0. + Thanks @chayim. #1559 + * Added support for QUIT available in Redis 1.0.0. + Thanks @chayim. #1558 + * Added support for COMMAND COUNT available in Redis 2.8.13. + Thanks @chayim. #1554. + * Added CREATECONSUMER support for XGROUP available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1553 + * Including slowly complexity in INFO if available. + Thanks @ian28223 #1489. + * Added support for STRALGO available in Redis 6.0.0. + Thanks @AvitalFineRedis. #1528 + * Addes support for ZMSCORE available in Redis 6.2.0. + Thanks @2014BDuck and @jiekun.zhu. #1437 + * Support MINID and LIMIT on XADD available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1548 + * Added sentinel commands FLUSHCONFIG, CKQUORUM, FAILOVER, and RESET + available in Redis 2.8.12. + Thanks @otherpirate. #834 + * Migrated Version instead of StrictVersion for Python 3.10. + Thanks @tirkarthi. #1552 + * Added retry mechanism with backoff. Thanks @nbraun-amazon. #1494 * Migrated commands to a mixin. Thanks @chayim. #1534 * Added support for ZUNION, available in Redis 6.2.0. Thanks @AvitalFineRedis. #1522 @@ -60,8 +89,14 @@ Thanks @AvitalFineRedis. #1515 * Added support for COPY command, available in Redis 6.2.0. Thanks @malinaa96. #1492 - * Added support for COPY command, available in Redis 6.2.0. - Thanks @malinaa96. #1492 + * Provide a development and testing environment via docker. Thanks + @abrookins. #1365 + * Added support for the LPOS command available in Redis 6.0.6. Thanks + @aparcar #1353/#1354 + * Added support for the ACL LOG command available in Redis 6. Thanks + @2014BDuck. #1307 + * Added support for ABSTTL option of the RESTORE command available in + Redis 5.0. Thanks @charettes. #1423 * 3.5.3 (June 1, 2020) * Restore try/except clauses to __del__ methods. These will be removed in 4.0 when more explicit resource management if enforced. #1339 From 491c938f9c6ea56fb0b3403be63168e6930709ae Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 20 Sep 2021 20:37:16 +0300 Subject: [PATCH 0158/1164] repalce redislabs with redis (#1575) Rename "Redis Labs" to "Redis" in the README docs --- README.rst | 6 +++--- docs/logo-redis.png | Bin 0 -> 8388 bytes docs/logo-redislabs.png | Bin 18950 -> 0 bytes 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 docs/logo-redis.png delete mode 100644 docs/logo-redislabs.png diff --git a/README.rst b/README.rst index 43a7de034a..ec87e24fe3 100644 --- a/README.rst +++ b/README.rst @@ -964,6 +964,6 @@ Special thanks to: Sponsored by ^^^^^^^^^^^^ -.. image:: ./docs/logo-redislabs.png - :alt: RedisLabs - :target: https://www.redislabs.com +.. image:: ./docs/logo-redis.png + :alt: Redis + :target: https://www.redis.com diff --git a/docs/logo-redis.png b/docs/logo-redis.png new file mode 100644 index 0000000000000000000000000000000000000000..45b4a3f284f3d10b558c2f77b75cd11739a117f7 GIT binary patch literal 8388 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 Date: Thu, 30 Sep 2021 10:54:07 +0300 Subject: [PATCH 0159/1164] Supporting args with MODULE LOAD (#1579) Part of #1546 --- redis/commands.py | 6 ++++-- tests/test_commands.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 5f1f57b305..92a9959ca3 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -3081,12 +3081,14 @@ def _geosearchgeneric(self, command, *args, **kwargs): return self.execute_command(command, *pieces, **kwargs) # MODULE COMMANDS - def module_load(self, path): + def module_load(self, path, *args): """ Loads the module from ``path``. Raises ``ModuleError`` if a module is not found at ``path``. """ - return self.execute_command('MODULE LOAD', path) + pieces = list(args) + pieces.insert(0, path) + return self.execute_command('MODULE LOAD', *pieces) def module_unload(self, name): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index 254aba5f59..9f0a148c31 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3461,6 +3461,16 @@ def test_command_count(self, r): assert isinstance(res, int) assert res >= 100 + @skip_if_server_version_lt('4.0.0') + def test_module(self, r): + with pytest.raises(redis.exceptions.ModuleError) as excinfo: + r.module_load('/some/fake/path') + assert "Error loading the extension." in str(excinfo.value) + + with pytest.raises(redis.exceptions.ModuleError) as excinfo: + r.module_load('/some/fake/path', 'arg1', 'arg2', 'arg3', 'arg4') + assert "Error loading the extension." in str(excinfo.value) + class TestBinarySave: From 53658c4f865c4f2e1f6810c6ce4e9b31a9dd7eaa Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 30 Sep 2021 11:48:37 +0300 Subject: [PATCH 0160/1164] IDLETIME and FREQ support for RESTORE (#1580) --- redis/commands.py | 24 +++++++++++++++++++++++- tests/test_commands.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/redis/commands.py b/redis/commands.py index 92a9959ca3..427f303c36 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1034,7 +1034,8 @@ def renamenx(self, src, dst): "Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist" return self.execute_command('RENAMENX', src, dst) - def restore(self, name, ttl, value, replace=False, absttl=False): + def restore(self, name, ttl, value, replace=False, absttl=False, + idletime=None, frequency=None): """ Create a key using the provided serialized value, previously obtained using DUMP. @@ -1045,12 +1046,32 @@ def restore(self, name, ttl, value, replace=False, absttl=False): ``absttl`` if True, specified ``ttl`` should represent an absolute Unix timestamp in milliseconds in which the key will expire. (Redis 5.0 or greater). + + ``idletime`` Used for eviction, this is the number of seconds the + key must be idle, prior to execution. + + ``frequency`` Used for eviction, this is the frequency counter of + the object stored at the key, prior to execution. """ params = [name, ttl, value] if replace: params.append('REPLACE') if absttl: params.append('ABSTTL') + if idletime is not None: + params.append('IDLETIME') + try: + params.append(int(idletime)) + except ValueError: + raise DataError("idletimemust be an integer") + + if frequency is not None: + params.append('FREQ') + try: + params.append(int(frequency)) + except ValueError: + raise DataError("frequency must be an integer") + return self.execute_command('RESTORE', *params) def set(self, name, value, @@ -3084,6 +3105,7 @@ def _geosearchgeneric(self, command, *args, **kwargs): def module_load(self, path, *args): """ Loads the module from ``path``. + Passes all ``*args`` to the module, during loading. Raises ``ModuleError`` if a module is not found at ``path``. """ pieces = list(args) diff --git a/tests/test_commands.py b/tests/test_commands.py index 9f0a148c31..7b6f55eb20 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3471,6 +3471,48 @@ def test_module(self, r): r.module_load('/some/fake/path', 'arg1', 'arg2', 'arg3', 'arg4') assert "Error loading the extension." in str(excinfo.value) + @skip_if_server_version_lt('2.6.0') + def test_restore(self, r): + + # standard restore + key = 'foo' + r.set(key, 'bar') + dumpdata = r.dump(key) + r.delete(key) + assert r.restore(key, 0, dumpdata) + assert r.get(key) == b'bar' + + # overwrite restore + with pytest.raises(redis.exceptions.ResponseError): + assert r.restore(key, 0, dumpdata) + r.set(key, 'a new value!') + assert r.restore(key, 0, dumpdata, replace=True) + assert r.get(key) == b'bar' + + # ttl check + key2 = 'another' + r.set(key2, 'blee!') + dumpdata = r.dump(key2) + r.delete(key2) + assert r.restore(key2, 0, dumpdata) + assert r.ttl(key2) == -1 + + # idletime + key = 'yayakey' + r.set(key, 'blee!') + dumpdata = r.dump(key) + r.delete(key) + assert r.restore(key, 0, dumpdata, idletime=5) + assert r.get(key) == b'blee!' + + # frequency + key = 'yayakey' + r.set(key, 'blee!') + dumpdata = r.dump(key) + r.delete(key) + assert r.restore(key, 0, dumpdata, frequency=5) + assert r.get(key) == b'blee!' + class TestBinarySave: From 799c13965f99e0863dae5aa712d70733d9cff210 Mon Sep 17 00:00:00 2001 From: "Jan C. Brammer" Date: Thu, 30 Sep 2021 12:30:30 +0200 Subject: [PATCH 0161/1164] Fix RST syntax error in README. (#1451) --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index ec87e24fe3..710b8a68c0 100644 --- a/README.rst +++ b/README.rst @@ -744,6 +744,7 @@ appropriately. The exception handler will take as arguments the exception itself, the pubsub object, and the worker thread returned by `run_in_thread`. .. code-block:: pycon + >>> p.subscribe(**{'my-channel': my_handler}) >>> def exception_handler(ex, pubsub, thread): >>> print(ex) From bfc4cd92c8070de9bfa7736ef21b44eb6fe35ed9 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Thu, 30 Sep 2021 03:34:39 -0700 Subject: [PATCH 0162/1164] Auto-reconnect PubSub on `get_message` (#1574) --- redis/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/redis/client.py b/redis/client.py index 7a022a0671..cbdb13d4fd 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1259,7 +1259,10 @@ def parse_response(self, block=True, timeout=0): self.check_health() - if not block and not conn.can_read(timeout=timeout): + if( + not block + and not self._execute(conn, conn.can_read, timeout=timeout) + ): return None response = self._execute(conn, conn.read_response) From 6c70fcdd10cca7de15175a77824009babfac4417 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 30 Sep 2021 13:35:10 +0300 Subject: [PATCH 0163/1164] CLIENT REPLY support, available since redis 3.2.0 (#1581) --- redis/commands.py | 19 +++++++++++++++++++ tests/conftest.py | 6 ++++++ tests/test_commands.py | 13 +++++++++++++ 3 files changed, 38 insertions(+) diff --git a/redis/commands.py b/redis/commands.py index 427f303c36..4266f9c55f 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -376,6 +376,25 @@ def client_getname(self): "Returns the current connection name" return self.execute_command('CLIENT GETNAME') + def client_reply(self, reply): + """Enable and disable redis server replies. + ``reply`` Must be ON OFF or SKIP, + ON - The default most with server replies to commands + OFF - Disable server responses to commands + SKIP - Skip the response of the immediately following command. + + Note: When setting OFF or SKIP replies, you will need a client object + with a timeout specified in seconds, and will need to catch the + TimeoutError. + The test_client_reply unit test illustrates this, and + conftest.py has a client with a timeout. + See https://redis.io/commands/client-reply + """ + replies = ['ON', 'OFF', 'SKIP'] + if reply not in replies: + raise DataError('CLIENT REPLY must be one of %r' % replies) + return self.execute_command("CLIENT REPLY", reply) + def client_id(self): "Returns the current connection id" return self.execute_command('CLIENT ID') diff --git a/tests/conftest.py b/tests/conftest.py index fe304c2cc2..b9091a96e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -100,6 +100,12 @@ def r(request): yield client +@pytest.fixture() +def r_timeout(request): + with _get_client(redis.Redis, request, socket_timeout=1) as client: + yield client + + @pytest.fixture() def r2(request): "A second client for tests that need multiple" diff --git a/tests/test_commands.py b/tests/test_commands.py index 7b6f55eb20..a283afcbaa 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -478,6 +478,19 @@ def test_client_pause(self, r): def test_client_unpause(self, r): assert r.client_unpause() == b'OK' + @skip_if_server_version_lt('3.2.0') + def test_client_reply(self, r, r_timeout): + assert r_timeout.client_reply('ON') == b'OK' + with pytest.raises(exceptions.TimeoutError): + r_timeout.client_reply('OFF') + + r_timeout.client_reply('SKIP') + + assert r_timeout.set('foo', 'bar') + + # validate it was set + assert r.get('foo') == b'bar' + def test_config_get(self, r): data = r.config_get() assert 'maxmemory' in data From 9527ae88538735c10def3716cdae9388cdee2388 Mon Sep 17 00:00:00 2001 From: Rajiv Bakulesh Shah Date: Thu, 30 Sep 2021 21:14:31 -0700 Subject: [PATCH 0164/1164] Implement/test LOLWUT command (#1568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement/test LOLWUT command https://redis.io/commands/lolwut This is a lot of fun to play with: ```python >>> from redis import Redis >>> redis = Redis() >>> print(redis.lolwut(5, 6, 7, 8).decode('utf-8')) ⣴⣶⣶⣶⣶⡆ ⣿⣿⣿⣿⣿⡇ ⠹⡿⠟⣿⡿⠃ ⠀⠀⠀⠀⠀⠀ Georg Nees - schotter, plotter on paper, 1968. Redis ver. 6.0.10 >>> print(redis.lolwut(5, 6, 7, 8).decode('utf-8')) ⢰⣶⣶⣶⣶⡆ ⢿⣿⣿⣿⣿⠁ ⠸⡿⢿⠿⡿⠃ ⠀⠀⠀⠀⠀⠀ Georg Nees - schotter, plotter on paper, 1968. Redis ver. 6.0.10 >>> print(redis.lolwut(5, 6, 7, 8).decode('utf-8')) ⢰⣶⣶⣶⣶⡆ ⣸⣿⣿⣻⣿⡅ ⠿⡿⠻⠿⠿⠁ ⠀⠀⠀⠀⠀⠀ Georg Nees - schotter, plotter on paper, 1968. Redis ver. 6.0.10 >>> ``` * Add link to LOLWUT command documentation Co-authored-by: Chayim * Skip LOLWUT unit test for Redis < 5.0.0 The `LOLWUT` command was introduced in Redis 5.0.0: https://redis.io/commands/lolwut Co-authored-by: Chayim --- redis/commands.py | 9 +++++++++ tests/test_commands.py | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/redis/commands.py b/redis/commands.py index 4266f9c55f..48d234ea35 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -523,6 +523,15 @@ def lastsave(self): """ return self.execute_command('LASTSAVE') + def lolwut(self, *version_numbers): + """Get the Redis version and a piece of generative computer art + See: https://redis.io/commands/lolwut + """ + if version_numbers: + return self.execute_command('LOLWUT VERSION', *version_numbers) + else: + return self.execute_command('LOLWUT') + def migrate(self, host, port, keys, destination_db, timeout, copy=False, replace=False, auth=None): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index a283afcbaa..736ae45174 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -531,6 +531,14 @@ def test_info(self, r): def test_lastsave(self, r): assert isinstance(r.lastsave(), datetime.datetime) + @skip_if_server_version_lt('5.0.0') + def test_lolwut(self, r): + lolwut = r.lolwut().decode('utf-8') + assert 'Redis ver.' in lolwut + + lolwut = r.lolwut(5, 6, 7, 8).decode('utf-8') + assert 'Redis ver.' in lolwut + def test_object(self, r): r['a'] = 'foo' assert isinstance(r.object('refcount', 'a'), int) From 677476d734d178e4ad89538042567367f703512b Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Tue, 5 Oct 2021 06:12:01 -0400 Subject: [PATCH 0165/1164] Fix potential test case typo in test_zadd_gt_lt (#1585) --- tests/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 736ae45174..60b9ff15bf 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1687,7 +1687,7 @@ def test_zadd_gt_lt(self, r): with pytest.raises(exceptions.DataError): r.zadd('a', {'a15': 155}, nx=True, lt=True) r.zadd('a', {'a15': 155}, nx=True, gt=True) - r.zadd('a', {'a15': 155}, lx=True, gt=True) + r.zadd('a', {'a15': 155}, lt=True, gt=True) def test_zcard(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) From 9419f1d56beff5b350c87785cc57f2431fe85cbb Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Tue, 5 Oct 2021 10:38:32 -0400 Subject: [PATCH 0166/1164] Use Oxford comma properly in getex (#1586) --- redis/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/commands.py b/redis/commands.py index 48d234ea35..eb7cea54f6 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -843,7 +843,7 @@ def getex(self, name, opset = set([ex, px, exat, pxat]) if len(opset) > 2 or len(opset) > 1 and persist: - raise DataError("``ex``, ``px``, ``exat``, ``pxat``", + raise DataError("``ex``, ``px``, ``exat``, ``pxat``, " "and ``persist`` are mutually exclusive.") pieces = [] From 22f677f991f811e5a540294f1535c3539719087f Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 11 Oct 2021 11:00:45 +0300 Subject: [PATCH 0167/1164] Removing the REDIS_6_VERSION placeholder (#1582) --- tests/conftest.py | 6 ------ tests/test_commands.py | 31 +++++++++++++++---------------- tests/test_connection_pool.py | 12 ++++++------ 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b9091a96e6..3dc3ea149e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,12 +9,6 @@ from urllib.parse import urlparse -# redis 6 release candidates report a version number of 5.9.x. Use this -# constant for skip_if decorators as a placeholder until 6.0.0 is officially -# released -REDIS_6_VERSION = '5.9.0' - - REDIS_INFO = {} default_redis_url = "redis://localhost:6379/9" diff --git a/tests/test_commands.py b/tests/test_commands.py index 60b9ff15bf..2be8923e0e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -11,7 +11,6 @@ from .conftest import ( _get_client, - REDIS_6_VERSION, skip_if_server_version_gte, skip_if_server_version_lt, skip_unless_arch_bits, @@ -68,19 +67,19 @@ def test_command_on_invalid_key_type(self, r): r['a'] # SERVER INFORMATION - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_cat_no_category(self, r): categories = r.acl_cat() assert isinstance(categories, list) assert 'read' in categories - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_cat_with_category(self, r): commands = r.acl_cat('read') assert isinstance(commands, list) assert 'get' in commands - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_deluser(self, r, request): username = 'redis-py-user' @@ -104,7 +103,7 @@ def teardown(): assert r.acl_getuser(users[3]) is None assert r.acl_getuser(users[4]) is None - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_genpass(self, r): password = r.acl_genpass() assert isinstance(password, str) @@ -117,7 +116,7 @@ def test_acl_genpass(self, r): r.acl_genpass(555) assert isinstance(password, str) - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_getuser_setuser(self, r, request): username = 'redis-py-user' @@ -204,13 +203,13 @@ def teardown(): hashed_passwords=['-' + hashed_password]) assert len(r.acl_getuser(username)['passwords']) == 1 - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_help(self, r): res = r.acl_help() assert isinstance(res, list) assert len(res) != 0 - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_list(self, r, request): username = 'redis-py-user' @@ -222,7 +221,7 @@ def teardown(): users = r.acl_list() assert len(users) == 2 - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_log(self, r, request): username = 'redis-py-user' @@ -257,7 +256,7 @@ def teardown(): assert 'client-info' in r.acl_log(count=1)[0] assert r.acl_log_reset() - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_setuser_categories_without_prefix_fails(self, r, request): username = 'redis-py-user' @@ -268,7 +267,7 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, categories=['list']) - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_setuser_commands_without_prefix_fails(self, r, request): username = 'redis-py-user' @@ -279,7 +278,7 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, commands=['get']) - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request): username = 'redis-py-user' @@ -290,13 +289,13 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, passwords='+mypass', nopass=True) - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_users(self, r): users = r.acl_users() assert isinstance(users, list) assert len(users) > 0 - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_acl_whoami(self, r): username = r.acl_whoami() assert isinstance(username, str) @@ -1137,7 +1136,7 @@ def test_set_multipleoptions(self, r): assert r.set('a', '1', xx=True, px=10000) assert 0 < r.ttl('a') <= 10 - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_set_keepttl(self, r): r['a'] = 'val' assert r.set('a', '1', xx=True, px=10000) @@ -1451,7 +1450,7 @@ def test_scan(self, r): _, keys = r.scan(match='a') assert set(keys) == {b'a'} - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_scan_type(self, r): r.sadd('a-set', 1) r.hset('a-hash', 'foo', 2) diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 7f9d05453d..8d2ad041a0 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -7,7 +7,7 @@ from threading import Thread from redis.connection import ssl_available, to_bool -from .conftest import skip_if_server_version_lt, _get_client, REDIS_6_VERSION +from .conftest import skip_if_server_version_lt, _get_client from .test_pubsub import wait_for_message @@ -200,7 +200,7 @@ def test_port(self): 'port': 6380, } - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_username(self): pool = redis.ConnectionPool.from_url('redis://myuser:@localhost') assert pool.connection_class == redis.Connection @@ -209,7 +209,7 @@ def test_username(self): 'username': 'myuser', } - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_quoted_username(self): pool = redis.ConnectionPool.from_url( 'redis://%2Fmyuser%2F%2B name%3D%24+:@localhost') @@ -236,7 +236,7 @@ def test_quoted_password(self): 'password': '/mypass/+ word=$+', } - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_username_and_password(self): pool = redis.ConnectionPool.from_url('redis://myuser:mypass@localhost') assert pool.connection_class == redis.Connection @@ -349,7 +349,7 @@ def test_defaults(self): 'path': '/socket', } - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_username(self): pool = redis.ConnectionPool.from_url('unix://myuser:@/socket') assert pool.connection_class == redis.UnixDomainSocketConnection @@ -358,7 +358,7 @@ def test_username(self): 'username': 'myuser', } - @skip_if_server_version_lt(REDIS_6_VERSION) + @skip_if_server_version_lt("6.0.0") def test_quoted_username(self): pool = redis.ConnectionPool.from_url( 'unix://%2Fmyuser%2F%2B name%3D%24+:@/socket') From 5cd878de3eca3322d6719822d4999595f943b218 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 11 Oct 2021 11:05:50 +0300 Subject: [PATCH 0168/1164] Making 3.9.9 a placeholder version - prior to 4.0.0 (#1599) --- redis/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/__init__.py b/redis/__init__.py index 9f67f51d08..f0193d8582 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -31,7 +31,7 @@ def int_or_str(value): return value -__version__ = '3.5.3' +__version__ = '3.9.9' VERSION = tuple(map(int_or_str, __version__.split('.'))) __all__ = [ From 90468846acb7812df6419d54a9c191156e170516 Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Wed, 13 Oct 2021 15:33:49 +0200 Subject: [PATCH 0169/1164] Add support to ANY to GEOSEARCHSTORE and to GEOSEARCH --- redis/commands.py | 31 +++++++++++++++++++------------ tests/test_commands.py | 6 ++++++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index eb7cea54f6..ecf5b5137a 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2908,7 +2908,7 @@ def geopos(self, name, *values): def georadius(self, name, longitude, latitude, radius, unit=None, withdist=False, withcoord=False, withhash=False, count=None, - sort=None, store=None, store_dist=None): + sort=None, store=None, store_dist=None, any=False): """ Return the members of the specified key identified by the ``name`` argument which are within the borders of the area specified @@ -2942,11 +2942,12 @@ def georadius(self, name, longitude, latitude, radius, unit=None, unit=unit, withdist=withdist, withcoord=withcoord, withhash=withhash, count=count, sort=sort, store=store, - store_dist=store_dist) + store_dist=store_dist, any=any) def georadiusbymember(self, name, member, radius, unit=None, withdist=False, withcoord=False, withhash=False, - count=None, sort=None, store=None, store_dist=None): + count=None, sort=None, store=None, store_dist=None, + any=False): """ This command is exactly like ``georadius`` with the sole difference that instead of taking, as the center of the area to query, a longitude @@ -2958,7 +2959,7 @@ def georadiusbymember(self, name, member, radius, unit=None, withdist=withdist, withcoord=withcoord, withhash=withhash, count=count, sort=sort, store=store, - store_dist=store_dist) + store_dist=store_dist, any=any) def _georadiusgeneric(self, command, *args, **kwargs): pieces = list(args) @@ -2969,21 +2970,26 @@ def _georadiusgeneric(self, command, *args, **kwargs): else: pieces.append('m',) + if kwargs['any'] and kwargs['count'] is None: + raise DataError("``any`` can't be provided without ``count``") + for arg_name, byte_repr in ( - ('withdist', b'WITHDIST'), - ('withcoord', b'WITHCOORD'), - ('withhash', b'WITHHASH')): + ('withdist', 'WITHDIST'), + ('withcoord', 'WITHCOORD'), + ('withhash', 'WITHHASH')): if kwargs[arg_name]: pieces.append(byte_repr) - if kwargs['count']: - pieces.extend([b'COUNT', kwargs['count']]) + if kwargs['count'] is not None: + pieces.extend(['COUNT', kwargs['count']]) + if kwargs['any']: + pieces.append('ANY') if kwargs['sort']: if kwargs['sort'] == 'ASC': - pieces.append(b'ASC') + pieces.append('ASC') elif kwargs['sort'] == 'DESC': - pieces.append(b'DESC') + pieces.append('DESC') else: raise DataError("GEORADIUS invalid sort") @@ -3116,7 +3122,8 @@ def _geosearchgeneric(self, command, *args, **kwargs): if kwargs['any']: pieces.append(b'ANY') elif kwargs['any']: - raise DataError("GEOSEARCH any can't be provided without count") + raise DataError("GEOSEARCH ``any`` can't be provided " + "without count") # other properties for arg_name, byte_repr in ( diff --git a/tests/test_commands.py b/tests/test_commands.py index 2be8923e0e..df79940913 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2655,6 +2655,9 @@ def test_georadius_count(self, r): r.geoadd('barcelona', *values) assert r.georadius('barcelona', 2.191, 41.433, 3000, count=1) == \ [b'place1'] + assert r.georadius('barcelona', 2.191, 41.433, 3000, + count=1, any=True) == \ + [b'place2'] @skip_if_server_version_lt('3.2.0') def test_georadius_sort(self, r): @@ -2706,6 +2709,9 @@ def test_georadiusmember(self, r): (2.187376320362091, 41.40634178640635)], [b'place1', 0.0, 3471609698139488, (2.1909382939338684, 41.433790281840835)]] + assert r.georadiusbymember('barcelona', 'place1', 4000, + count=1, any=True) == \ + [b'\x80place2'] @skip_if_server_version_lt('5.0.0') def test_xack(self, r): From c02ecb5c4f3d57da490a24e150cd386ad69580bd Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Wed, 13 Oct 2021 15:57:21 +0200 Subject: [PATCH 0170/1164] raise NotImplementedError --- redis/commands.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index eb7cea54f6..b1e43a922d 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -568,11 +568,16 @@ def migrate(self, host, port, keys, destination_db, timeout, timeout, *pieces) def object(self, infotype, key): - "Return the encoding, idletime, or refcount about the key" + """Return the encoding, idletime, or refcount about the key""" return self.execute_command('OBJECT', infotype, key, infotype=infotype) + def memory_doctor(self): + raise NotImplementedError( + "MEMORY DOCTOR is not supported in the client." + ) + def memory_stats(self): - "Return a dictionary of memory stats" + """Return a dictionary of memory stats""" return self.execute_command('MEMORY STATS') def memory_usage(self, key, samples=None): @@ -590,15 +595,16 @@ def memory_usage(self, key, samples=None): return self.execute_command('MEMORY USAGE', key, *args) def memory_purge(self): - "Attempts to purge dirty pages for reclamation by allocator" + """Attempts to purge dirty pages for reclamation by allocator""" return self.execute_command('MEMORY PURGE') def ping(self): - "Ping the Redis server" + """Ping the Redis server""" return self.execute_command('PING') def quit(self): - """Ask the server to close the connection. + """ + Ask the server to close the connection. https://redis.io/commands/quit """ return self.execute_command('QUIT') From 9d92ca78fe694d57bfaf6929dcd40dc707302d2e Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Wed, 13 Oct 2021 16:00:09 +0200 Subject: [PATCH 0171/1164] stam --- redis/commands.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index b1e43a922d..1524bfd323 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -373,7 +373,7 @@ def client_list(self, _type=None, client_id=[]): return self.execute_command('CLIENT LIST', *args) def client_getname(self): - "Returns the current connection name" + """Returns the current connection name""" return self.execute_command('CLIENT GETNAME') def client_reply(self, reply): @@ -396,11 +396,12 @@ def client_reply(self, reply): return self.execute_command("CLIENT REPLY", reply) def client_id(self): - "Returns the current connection id" + """Returns the current connection id""" return self.execute_command('CLIENT ID') def client_trackinginfo(self): - """Returns the information about the current client connection's + """ + Returns the information about the current client connection's use of the server assisted client side cache. See https://redis.io/commands/client-trackinginfo """ @@ -438,15 +439,15 @@ def client_unpause(self): return self.execute_command('CLIENT UNPAUSE') def readwrite(self): - "Disables read queries for a connection to a Redis Cluster slave node" + """Disables read queries for a connection to a Redis Cluster slave node""" return self.execute_command('READWRITE') def readonly(self): - "Enables read queries for a connection to a Redis Cluster replica node" + """Enables read queries for a connection to a Redis Cluster replica node""" return self.execute_command('READONLY') def config_get(self, pattern="*"): - "Return a dictionary of configuration based on the ``pattern``" + """Return a dictionary of configuration based on the ``pattern``""" return self.execute_command('CONFIG GET', pattern) def config_set(self, name, value): @@ -454,23 +455,23 @@ def config_set(self, name, value): return self.execute_command('CONFIG SET', name, value) def config_resetstat(self): - "Reset runtime statistics" + """Reset runtime statistics""" return self.execute_command('CONFIG RESETSTAT') def config_rewrite(self): - "Rewrite config file with the minimal change to reflect running config" + """Rewrite config file with the minimal change to reflect running config""" return self.execute_command('CONFIG REWRITE') def dbsize(self): - "Returns the number of keys in the current database" + """Returns the number of keys in the current database""" return self.execute_command('DBSIZE') def debug_object(self, key): - "Returns version specific meta information about a given key" + """Returns version specific meta information about a given key""" return self.execute_command('DEBUG OBJECT', key) def echo(self, value): - "Echo the string back from the server" + """Echo the string back from the server""" return self.execute_command('ECHO', value) def flushall(self, asynchronous=False): @@ -524,7 +525,8 @@ def lastsave(self): return self.execute_command('LASTSAVE') def lolwut(self, *version_numbers): - """Get the Redis version and a piece of generative computer art + """ + Get the Redis version and a piece of generative computer art See: https://redis.io/commands/lolwut """ if version_numbers: From b559b6d82df3214931d2b4cadc9d380127953b4f Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Wed, 13 Oct 2021 16:58:11 +0200 Subject: [PATCH 0172/1164] flake8 --- redis/commands.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 1524bfd323..11205cf3ce 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -439,11 +439,15 @@ def client_unpause(self): return self.execute_command('CLIENT UNPAUSE') def readwrite(self): - """Disables read queries for a connection to a Redis Cluster slave node""" + """ + Disables read queries for a connection to a Redis Cluster slave node. + """ return self.execute_command('READWRITE') def readonly(self): - """Enables read queries for a connection to a Redis Cluster replica node""" + """ + Enables read queries for a connection to a Redis Cluster replica node. + """ return self.execute_command('READONLY') def config_get(self, pattern="*"): @@ -459,7 +463,9 @@ def config_resetstat(self): return self.execute_command('CONFIG RESETSTAT') def config_rewrite(self): - """Rewrite config file with the minimal change to reflect running config""" + """ + Rewrite config file with the minimal change to reflect running config. + """ return self.execute_command('CONFIG REWRITE') def dbsize(self): From 106567de0573d2c6d8651191c7d108802d10a06d Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Thu, 14 Oct 2021 08:32:10 +0200 Subject: [PATCH 0173/1164] Throw NotImplementedError --- redis/commands.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/redis/commands.py b/redis/commands.py index eb7cea54f6..766d391f17 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -3154,6 +3154,11 @@ def module_list(self): """ return self.execute_command('MODULE LIST') + def command(self): + raise NotImplementedError( + "COMMAND is not supported in the client." + ) + def command_count(self): return self.execute_command('COMMAND COUNT') From 1b985b16ac73f9b2eaa6bf70338a6a9049e817aa Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Thu, 14 Oct 2021 08:40:17 +0200 Subject: [PATCH 0174/1164] Throw NotImplementedError --- redis/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/commands.py b/redis/commands.py index 766d391f17..d8d6e088cf 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -3156,7 +3156,7 @@ def module_list(self): def command(self): raise NotImplementedError( - "COMMAND is not supported in the client." + "COMMAND is intentionally not implemented in the client." ) def command_count(self): From 4eeceb0b7ebdf8fb164d1c68cd9c64d320bf9241 Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Thu, 14 Oct 2021 08:40:57 +0200 Subject: [PATCH 0175/1164] Throw NotImplementedError --- redis/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/commands.py b/redis/commands.py index 11205cf3ce..4a8d430da2 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -581,7 +581,7 @@ def object(self, infotype, key): def memory_doctor(self): raise NotImplementedError( - "MEMORY DOCTOR is not supported in the client." + "MEMORY DOCTOR is intentionally not implemented in the client." ) def memory_stats(self): From b66187ba13cfe4fa8a31dc3107f9d3783154d1b7 Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Thu, 14 Oct 2021 09:02:16 +0200 Subject: [PATCH 0176/1164] Throw NotImplementedError --- redis/commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index d8d6e088cf..bc805076a3 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -3154,9 +3154,9 @@ def module_list(self): """ return self.execute_command('MODULE LIST') - def command(self): + def command_info(self): raise NotImplementedError( - "COMMAND is intentionally not implemented in the client." + "COMMAND INFO is intentionally not implemented in the client." ) def command_count(self): From 9c34614c1a998267b2ea454544a0fff243c0b943 Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Thu, 14 Oct 2021 09:03:02 +0200 Subject: [PATCH 0177/1164] Throw NotImplementedError --- redis/commands.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/redis/commands.py b/redis/commands.py index 4a8d430da2..bcb1c8457b 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -584,6 +584,11 @@ def memory_doctor(self): "MEMORY DOCTOR is intentionally not implemented in the client." ) + def memory_HELP(self): + raise NotImplementedError( + "MEMORY HELP is intentionally not implemented in the client." + ) + def memory_stats(self): """Return a dictionary of memory stats""" return self.execute_command('MEMORY STATS') From 5fb24c43af334d16878c0db5104270a76e73f188 Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Thu, 14 Oct 2021 11:48:56 +0200 Subject: [PATCH 0178/1164] Throw NotImplementedError --- redis/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/commands.py b/redis/commands.py index bcb1c8457b..23b5aac9ec 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -584,7 +584,7 @@ def memory_doctor(self): "MEMORY DOCTOR is intentionally not implemented in the client." ) - def memory_HELP(self): + def memory_help(self): raise NotImplementedError( "MEMORY HELP is intentionally not implemented in the client." ) From b63e55e927f3ca205a178c14330e6091120cf366 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Thu, 14 Oct 2021 14:55:07 +0300 Subject: [PATCH 0179/1164] Updating links to point to the new repository location (#1611) --- README.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 710b8a68c0..821a25386d 100644 --- a/README.rst +++ b/README.rst @@ -3,14 +3,14 @@ redis-py The Python interface to the Redis key-value store. -.. image:: https://github.com/andymccurdy/redis-py/workflows/CI/badge.svg?branch=master - :target: https://github.com/andymccurdy/redis-py/actions?query=workflow%3ACI+branch%3Amaster +.. image:: https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master + :target: https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster .. image:: https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat :target: https://redis-py.readthedocs.io/en/stable/ .. image:: https://badge.fury.io/py/redis.svg :target: https://pypi.org/project/redis/ -.. image:: https://codecov.io/gh/andymccurdy/redis-py/branch/master/graph/badge.svg - :target: https://codecov.io/gh/andymccurdy/redis-py +.. image:: https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg + :target: https://codecov.io/gh/redis/redis-py Python 2 Compatibility Note @@ -51,7 +51,7 @@ Contributing ------------ Want to contribute a feature, bug report, or report an issue? Check out our `guide to -contributing `_. +contributing `_. Getting Started @@ -127,7 +127,7 @@ SSL Connections redis-py 3.0 changes the default value of the `ssl_cert_reqs` option from `None` to `'required'`. See -`Issue 1016 `_. This +`Issue 1016 `_. This change enforces hostname validation when accepting a cert from a remote SSL terminator. If the terminator doesn't properly set the hostname on the cert this will cause redis-py 3.0 to raise a ConnectionError. @@ -284,7 +284,7 @@ to the official command syntax. There are a few exceptions: will return a PubSub instance where you can subscribe to channels and listen for messages. You can only call PUBLISH from the Redis client (see `this comment on issue #151 - `_ + `_ for details). * **SCAN/SSCAN/HSCAN/ZSCAN**: The \*SCAN commands are implemented as they exist in the Redis documentation. In addition, each command has an equivalent @@ -952,7 +952,7 @@ Author ^^^^^^ redis-py is developed and maintained by Andy McCurdy (sedrik@gmail.com). -It can be found here: https://github.com/andymccurdy/redis-py +It can be found here: https://github.com/redis/redis-py Special thanks to: From 3758579b42c4cc7546343dcf348891e933cb922f Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Thu, 14 Oct 2021 15:21:19 +0200 Subject: [PATCH 0180/1164] implement memory_malloc_stats --- redis/commands.py | 4 ++++ tests/test_commands.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/redis/commands.py b/redis/commands.py index eb7cea54f6..bbd422f5b3 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -575,6 +575,10 @@ def memory_stats(self): "Return a dictionary of memory stats" return self.execute_command('MEMORY STATS') + def memory_malloc_stats(self): + """Return an internal statistics report from the memory allocator.""" + return self.execute_command('MEMORY MALLOC-STATS') + def memory_usage(self, key, samples=None): """ Return the total memory usage for key, its value and associated diff --git a/tests/test_commands.py b/tests/test_commands.py index 2be8923e0e..21f259717b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3454,6 +3454,10 @@ def test_bitfield_operations(self, r): .execute()) assert resp == [0, None, 255] + @skip_if_server_version_lt('4.0.0') + def test_memory_malloc_stats(self, r): + assert r.memory_malloc_stats() + @skip_if_server_version_lt('4.0.0') def test_memory_stats(self, r): # put a key into the current db to make sure that "db." From 65c433407dc02aff90fc41f63ea46b372b374b9b Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Thu, 14 Oct 2021 09:36:09 -0400 Subject: [PATCH 0181/1164] Simplify MODULE LOAD and fix SCRIPT FLUSH doc (#1597) --- redis/commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 7bb6b501a2..e10238a5ec 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2861,7 +2861,7 @@ def script_flush(self, sync_type="SYNC"): See: https://redis.io/commands/script-flush """ if sync_type not in ["SYNC", "ASYNC"]: - raise DataError("SCRIPT FLUSH defaults to SYNC or" + raise DataError("SCRIPT FLUSH defaults to SYNC or " "accepts SYNC/ASYNC") pieces = [sync_type] return self.execute_command('SCRIPT FLUSH', *pieces) @@ -3155,9 +3155,7 @@ def module_load(self, path, *args): Passes all ``*args`` to the module, during loading. Raises ``ModuleError`` if a module is not found at ``path``. """ - pieces = list(args) - pieces.insert(0, path) - return self.execute_command('MODULE LOAD', *pieces) + return self.execute_command('MODULE LOAD', path, *args) def module_unload(self, name): """ From 3dc28e9bdeb745107d20a0be8a7ffc565a71da10 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Thu, 14 Oct 2021 09:36:22 -0400 Subject: [PATCH 0182/1164] Add client_id param to docs for client_list (#1589) --- redis/commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redis/commands.py b/redis/commands.py index e10238a5ec..94489d1d75 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -355,6 +355,7 @@ def client_list(self, _type=None, client_id=[]): If type of client specified, only that type will be returned. :param _type: optional. one of the client types (normal, master, replica, pubsub) + :param client_id: optional. a list of client ids """ "Returns a list of currently connected clients" args = [] @@ -2208,7 +2209,7 @@ def xtrim(self, name, maxlen=None, approximate=True, minid=None, """ pieces = [] if maxlen is not None and minid is not None: - raise DataError("Only one of ```maxlen``` or ```minid```", + raise DataError("Only one of ``maxlen`` or ``minid`` " "may be specified") if maxlen is not None: From 358c293e7772a44f114c78b5c5129ba1e972240f Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Thu, 14 Oct 2021 09:36:33 -0400 Subject: [PATCH 0183/1164] Remove unused BitFieldOperation in client.py (#1590) --- redis/client.py | 97 ------------------------------------------------- 1 file changed, 97 deletions(-) diff --git a/redis/client.py b/redis/client.py index cbdb13d4fd..2e2a26a2ad 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1862,100 +1862,3 @@ def __call__(self, keys=[], args=[], client=None): # Overwrite the sha just in case there was a discrepancy. self.sha = client.script_load(self.script) return client.evalsha(self.sha, len(keys), *args) - - -class BitFieldOperation: - """ - Command builder for BITFIELD commands. - """ - def __init__(self, client, key, default_overflow=None): - self.client = client - self.key = key - self._default_overflow = default_overflow - self.reset() - - def reset(self): - """ - Reset the state of the instance to when it was constructed - """ - self.operations = [] - self._last_overflow = 'WRAP' - self.overflow(self._default_overflow or self._last_overflow) - - def overflow(self, overflow): - """ - Update the overflow algorithm of successive INCRBY operations - :param overflow: Overflow algorithm, one of WRAP, SAT, FAIL. See the - Redis docs for descriptions of these algorithmsself. - :returns: a :py:class:`BitFieldOperation` instance. - """ - overflow = overflow.upper() - if overflow != self._last_overflow: - self._last_overflow = overflow - self.operations.append(('OVERFLOW', overflow)) - return self - - def incrby(self, fmt, offset, increment, overflow=None): - """ - Increment a bitfield by a given amount. - :param fmt: format-string for the bitfield being updated, e.g. 'u8' - for an unsigned 8-bit integer. - :param offset: offset (in number of bits). If prefixed with a - '#', this is an offset multiplier, e.g. given the arguments - fmt='u8', offset='#2', the offset will be 16. - :param int increment: value to increment the bitfield by. - :param str overflow: overflow algorithm. Defaults to WRAP, but other - acceptable values are SAT and FAIL. See the Redis docs for - descriptions of these algorithms. - :returns: a :py:class:`BitFieldOperation` instance. - """ - if overflow is not None: - self.overflow(overflow) - - self.operations.append(('INCRBY', fmt, offset, increment)) - return self - - def get(self, fmt, offset): - """ - Get the value of a given bitfield. - :param fmt: format-string for the bitfield being read, e.g. 'u8' for - an unsigned 8-bit integer. - :param offset: offset (in number of bits). If prefixed with a - '#', this is an offset multiplier, e.g. given the arguments - fmt='u8', offset='#2', the offset will be 16. - :returns: a :py:class:`BitFieldOperation` instance. - """ - self.operations.append(('GET', fmt, offset)) - return self - - def set(self, fmt, offset, value): - """ - Set the value of a given bitfield. - :param fmt: format-string for the bitfield being read, e.g. 'u8' for - an unsigned 8-bit integer. - :param offset: offset (in number of bits). If prefixed with a - '#', this is an offset multiplier, e.g. given the arguments - fmt='u8', offset='#2', the offset will be 16. - :param int value: value to set at the given position. - :returns: a :py:class:`BitFieldOperation` instance. - """ - self.operations.append(('SET', fmt, offset, value)) - return self - - @property - def command(self): - cmd = ['BITFIELD', self.key] - for ops in self.operations: - cmd.extend(ops) - return cmd - - def execute(self): - """ - Execute the operation(s) in a single BITFIELD command. The return value - is a list of values corresponding to each operation. If the client - used to create this instance was a pipeline, the list of values - will be present within the pipeline's execute. - """ - command = self.command - self.reset() - return self.client.execute_command(*command) From 9e8cfaac8bbab84c2340b55e5ac55289834b558b Mon Sep 17 00:00:00 2001 From: nbraun-amazon <85549956+nbraun-amazon@users.noreply.github.com> Date: Thu, 14 Oct 2021 16:41:15 +0300 Subject: [PATCH 0184/1164] Fix `retry` attribute in UnixDomainSocketConnection (#1604) --- redis/connection.py | 16 +++++++++++++++- tests/test_retry.py | 16 +++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/redis/connection.py b/redis/connection.py index de30f0c638..5528589026 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -880,7 +880,13 @@ def __init__(self, path='', db=0, username=None, password=None, encoding_errors='strict', decode_responses=False, retry_on_timeout=False, parser_class=DefaultParser, socket_read_size=65536, - health_check_interval=0, client_name=None): + health_check_interval=0, client_name=None, + retry=None): + """ + Initialize a new UnixDomainSocketConnection. + To specify a retry policy, first set `retry_on_timeout` to `True` + then set `retry` to a valid `Retry` object + """ self.pid = os.getpid() self.path = path self.db = db @@ -889,6 +895,14 @@ def __init__(self, path='', db=0, username=None, password=None, self.password = password self.socket_timeout = socket_timeout self.retry_on_timeout = retry_on_timeout + if retry_on_timeout: + if retry is None: + self.retry = Retry(NoBackoff(), 1) + else: + # deep-copy the Retry object as it is mutable + self.retry = copy.deepcopy(retry) + else: + self.retry = Retry(NoBackoff(), 0) self.health_check_interval = health_check_interval self.next_health_check = 0 self.encoder = Encoder(encoding, encoding_errors, decode_responses) diff --git a/tests/test_retry.py b/tests/test_retry.py index 24d9683f17..535485acae 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -2,7 +2,7 @@ import pytest from redis.exceptions import ConnectionError -from redis.connection import Connection +from redis.connection import Connection, UnixDomainSocketConnection from redis.retry import Retry @@ -20,20 +20,22 @@ def compute(self, failures): class TestConnectionConstructorWithRetry: - "Test that the Connection constructor properly handles Retry objects" + "Test that the Connection constructors properly handles Retry objects" @pytest.mark.parametrize("retry_on_timeout", [False, True]) - def test_retry_on_timeout_boolean(self, retry_on_timeout): - c = Connection(retry_on_timeout=retry_on_timeout) + @pytest.mark.parametrize("Class", [Connection, UnixDomainSocketConnection]) + def test_retry_on_timeout_boolean(self, Class, retry_on_timeout): + c = Class(retry_on_timeout=retry_on_timeout) assert c.retry_on_timeout == retry_on_timeout assert isinstance(c.retry, Retry) assert c.retry._retries == (1 if retry_on_timeout else 0) @pytest.mark.parametrize("retries", range(10)) - def test_retry_on_timeout_retry(self, retries): + @pytest.mark.parametrize("Class", [Connection, UnixDomainSocketConnection]) + def test_retry_on_timeout_retry(self, Class, retries): retry_on_timeout = retries > 0 - c = Connection(retry_on_timeout=retry_on_timeout, - retry=Retry(NoBackoff(), retries)) + c = Class(retry_on_timeout=retry_on_timeout, + retry=Retry(NoBackoff(), retries)) assert c.retry_on_timeout == retry_on_timeout assert isinstance(c.retry, Retry) assert c.retry._retries == retries From 0a55859a5ec3d5ab70ff1b0ea27bc13d91621ab9 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 14 Oct 2021 16:42:07 +0300 Subject: [PATCH 0185/1164] bringing CHANGES up-to-date (#1600) --- CHANGES | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGES b/CHANGES index 057423dac1..801e2f1253 100644 --- a/CHANGES +++ b/CHANGES @@ -4,16 +4,27 @@ urllib.parse.unquote. Prior versions of redis-py supported this by specifying the ``decode_components`` flag to the ``from_url`` functions. This is now done by default and cannot be disabled. #589 - * POTENTIALLY INCOMPATIBLE: Redis commands were moved into a mixin - (see commands.py). Anyone importing ``redis.client`` to access commands - directly should import ``redis.commands``. + * POTENTIALLY INCOMPATIBLE: Redis commands were moved into a mixin + (see commands.py). Anyone importing ``redis.client`` to access commands + directly should import ``redis.commands``. #1534, #1550 + * Removed technical debt on REDIS_6_VERSION placeholder. Thanks @chayim #1582. + * Various docus fixes. Thanks @Andrew-Chen-Wang #1585, #1586. + * Support for LOLWUT command, available since Redis 5.0.0. + Thanks @brainix #1568. + * Added support for CLIENT REPLY, available in Redis 3.2.0. + Thanks @chayim #1581. + * Support for Auto-reconnect PubSub on get_message. Thanks @luhn #1574. + * Fix RST syntax error in README/ Thanks @JanCBrammer #1451. + * IDLETIME and FREQ support for RESTORE. Thanks @chayim #1580. + * Supporting args with MODULE LOAD. Thanks @chayim #1579. + * Updating RedisLabs with Redis. Thanks @gkorland #1575. * Added support for ASYNC to SCRIPT FLUSH available in Redis 6.2.0. Thanks @chayim. #1567 - * Added CLIENT LIST fix to support multiple client ids available in + * Added CLIENT LIST fix to support multiple client ids available in Redis 2.8.12. Thanks @chayim #1563. * Added DISCARD support for pipelines available in Redis 2.0.0. Thanks @chayim #1565. - * Added ACL DELUSER support for deleting lists of users available in + * Added ACL DELUSER support for deleting lists of users available in Redis 6.2.0. Thanks @chayim. #1562 * Added CLIENT TRACKINFO support available in Redis 6.2.0. Thanks @chayim. #1560 @@ -31,10 +42,10 @@ Thanks @ian28223 #1489. * Added support for STRALGO available in Redis 6.0.0. Thanks @AvitalFineRedis. #1528 - * Addes support for ZMSCORE available in Redis 6.2.0. + * Addes support for ZMSCORE available in Redis 6.2.0. Thanks @2014BDuck and @jiekun.zhu. #1437 - * Support MINID and LIMIT on XADD available in Redis 6.2.0. - Thanks @AvitalFineRedis. #1548 + * Support MINID and LIMIT on XADD available in Redis 6.2.0. + Thanks @AvitalFineRedis. #1548 * Added sentinel commands FLUSHCONFIG, CKQUORUM, FAILOVER, and RESET available in Redis 2.8.12. Thanks @otherpirate. #834 From 2bb225a3238b2d221fff9d1200a05f1b5a9b4dd0 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 17 Oct 2021 11:12:24 +0300 Subject: [PATCH 0186/1164] 4.0.0 beta 1 versioning - prior to pypi release (#1615) --- redis/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/__init__.py b/redis/__init__.py index f0193d8582..a3b5eb67e3 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -31,7 +31,7 @@ def int_or_str(value): return value -__version__ = '3.9.9' +__version__ = '4.0.0b1' VERSION = tuple(map(int_or_str, __version__.split('.'))) __all__ = [ From cdd0beae029f03f5246d170c0ba0458cddcdb36b Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 17 Oct 2021 13:10:36 +0300 Subject: [PATCH 0187/1164] Adding the release drafter to help simplify release notes (#1618) --- .github/release-drafter-config.yml | 40 +++++++++++++++++++++++++++ .github/workflows/release-drafter.yml | 19 +++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 .github/release-drafter-config.yml create mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/release-drafter-config.yml b/.github/release-drafter-config.yml new file mode 100644 index 0000000000..5e429972b6 --- /dev/null +++ b/.github/release-drafter-config.yml @@ -0,0 +1,40 @@ +name-template: 'Version $NEXT_PATCH_VERSION' +tag-template: 'v$NEXT_PATCH_VERSION' +autolabeler: + - label: 'chore' + files: + - '*.md' + - '.github/*' + - label: 'bug' + branch: + - '/bug-.+' + - label: 'chore' + branch: + - '/chore-.+' + - label: 'feature' + branch: + - '/feature-.+' +categories: + - title: 'Breaking Changes' + labels: + - 'breakingchange' + - title: '🚀 New Features' + labels: + - 'feature' + - 'enhancement' + - title: '🐛 Bug Fixes' + labels: + - 'fix' + - 'bugfix' + - 'bug' + - title: '🧰 Maintenance' + label: 'chore' +change-template: '- $TITLE (#$NUMBER)' +exclude-labels: + - 'skip-changelog' +template: | + ## Changes + + $CHANGES + +We'd like to thank all of the contributors who have worked on this release! \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000000..ec2d88bf6e --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,19 @@ +name: Release Drafter + +on: + push: + # branches to consider in the event; optional, defaults to all + branches: + - master + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + # Drafts your next Release notes as Pull Requests are merged into "master" + - uses: release-drafter/release-drafter@v5 + with: + # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml + config-name: release-drafter-config.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 42227d232a35fd7b7b37f7e9341d4a65c0a6d0ff Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 17 Oct 2021 13:53:30 +0300 Subject: [PATCH 0188/1164] adding contributors to the changes (#1619) --- .github/release-drafter-config.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/release-drafter-config.yml b/.github/release-drafter-config.yml index 5e429972b6..f17a2992fa 100644 --- a/.github/release-drafter-config.yml +++ b/.github/release-drafter-config.yml @@ -37,4 +37,8 @@ template: | $CHANGES -We'd like to thank all of the contributors who have worked on this release! \ No newline at end of file + ## Contributors + We'd like to thank all the contributors who worked on this release! + + $CONTRIBUTORS + From 4f4adadc08f6e2726fe2d342e7d7751c036fb78f Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Mon, 18 Oct 2021 10:02:35 +0200 Subject: [PATCH 0189/1164] add support to `ZRANGE` and `ZRANGESTORE` parameters (#1603) --- redis/commands.py | 245 ++++++++++++++++++++++------------------- tests/test_commands.py | 58 ++++++++-- 2 files changed, 181 insertions(+), 122 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index baf5239ae4..ee883bcc74 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1073,7 +1073,7 @@ def hrandfield(self, key, count=None, withvalues=False): return self.execute_command("HRANDFIELD", key, *params) def randomkey(self): - "Returns the name of a random key" + """Returns the name of a random key""" return self.execute_command('RANDOMKEY') def rename(self, src, dst): @@ -1083,7 +1083,7 @@ def rename(self, src, dst): return self.execute_command('RENAME', src, dst) def renamenx(self, src, dst): - "Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist" + """Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist""" return self.execute_command('RENAMENX', src, dst) def restore(self, name, ttl, value, replace=False, absttl=False, @@ -1545,32 +1545,25 @@ def sort(self, name, start=None, num=None, by=None, get=None, pieces = [name] if by is not None: - pieces.append(b'BY') - pieces.append(by) + pieces.extend([b'BY', by]) if start is not None and num is not None: - pieces.append(b'LIMIT') - pieces.append(start) - pieces.append(num) + pieces.extend([b'LIMIT', start, num]) if get is not None: # If get is a string assume we want to get a single value. # Otherwise assume it's an interable and we want to get multiple # values. We can't just iterate blindly because strings are # iterable. if isinstance(get, (bytes, str)): - pieces.append(b'GET') - pieces.append(get) + pieces.extend([b'GET', get]) else: for g in get: - pieces.append(b'GET') - pieces.append(g) + pieces.extend([b'GET', g]) if desc: pieces.append(b'DESC') if alpha: pieces.append(b'ALPHA') if store is not None: - pieces.append(b'STORE') - pieces.append(store) - + pieces.extend([b'STORE', store]) if groups: if not get or isinstance(get, (bytes, str)) or len(get) < 2: raise DataError('when using "groups" the "get" argument ' @@ -1729,15 +1722,15 @@ def zscan_iter(self, name, match=None, count=None, # SET COMMANDS def sadd(self, name, *values): - "Add ``value(s)`` to set ``name``" + """Add ``value(s)`` to set ``name``""" return self.execute_command('SADD', name, *values) def scard(self, name): - "Return the number of elements in set ``name``" + """Return the number of elements in set ``name``""" return self.execute_command('SCARD', name) def sdiff(self, keys, *args): - "Return the difference of sets specified by ``keys``" + """Return the difference of sets specified by ``keys``""" args = list_or_args(keys, args) return self.execute_command('SDIFF', *args) @@ -1750,7 +1743,7 @@ def sdiffstore(self, dest, keys, *args): return self.execute_command('SDIFFSTORE', dest, *args) def sinter(self, keys, *args): - "Return the intersection of sets specified by ``keys``" + """Return the intersection of sets specified by ``keys``""" args = list_or_args(keys, args) return self.execute_command('SINTER', *args) @@ -1763,15 +1756,17 @@ def sinterstore(self, dest, keys, *args): return self.execute_command('SINTERSTORE', dest, *args) def sismember(self, name, value): - "Return a boolean indicating if ``value`` is a member of set ``name``" + """ + Return a boolean indicating if ``value`` is a member of set ``name`` + """ return self.execute_command('SISMEMBER', name, value) def smembers(self, name): - "Return all members of the set ``name``" + """Return all members of the set ``name``""" return self.execute_command('SMEMBERS', name) def smove(self, src, dst, value): - "Move ``value`` from set ``src`` to set ``dst`` atomically" + """Move ``value`` from set ``src`` to set ``dst`` atomically""" return self.execute_command('SMOVE', src, dst, value) def spop(self, name, count=None): @@ -1850,8 +1845,7 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True, pieces.append(b'~') pieces.append(minid) if limit is not None: - pieces.append(b"LIMIT") - pieces.append(limit) + pieces.extend([b'LIMIT', limit]) if nomkstream: pieces.append(b'NOMKSTREAM') pieces.append(id) @@ -2440,41 +2434,113 @@ def bzpopmin(self, keys, timeout=0): keys.append(timeout) return self.execute_command('BZPOPMIN', *keys) + def _zrange(self, command, dest, name, start, end, desc=False, + byscore=False, bylex=False, withscores=False, + score_cast_func=float, offset=None, num=None): + if byscore and bylex: + raise DataError("``byscore`` and ``bylex`` can not be " + "specified together.") + if (offset is not None and num is None) or \ + (num is not None and offset is None): + raise DataError("``offset`` and ``num`` must both be specified.") + if bylex and withscores: + raise DataError("``withscores`` not supported in combination " + "with ``bylex``.") + pieces = [command] + if dest: + pieces.append(dest) + pieces.extend([name, start, end]) + if byscore: + pieces.append('BYSCORE') + if bylex: + pieces.append('BYLEX') + if desc: + pieces.append('REV') + if offset is not None and num is not None: + pieces.extend(['LIMIT', offset, num]) + if withscores: + pieces.append('WITHSCORES') + options = { + 'withscores': withscores, + 'score_cast_func': score_cast_func + } + return self.execute_command(*pieces, **options) + def zrange(self, name, start, end, desc=False, withscores=False, - score_cast_func=float): + score_cast_func=float, byscore=False, bylex=False, + offset=None, num=None): """ Return a range of values from sorted set ``name`` between ``start`` and ``end`` sorted in ascending order. ``start`` and ``end`` can be negative, indicating the end of the range. - ``desc`` a boolean indicating whether to sort the results descendingly + ``desc`` a boolean indicating whether to sort the results in reversed + order. ``withscores`` indicates to return the scores along with the values. + The return type is a list of (value, score) pairs. + + ``score_cast_func`` a callable used to cast the score return value. + + ``byscore`` when set to True, returns the range of elements from the + sorted set having scores equal or between ``start`` and ``end``. + + ``bylex`` when set to True, returns the range of elements from the + sorted set between the ``start`` and ``end`` lexicographical closed + range intervals. + Valid ``start`` and ``end`` must start with ( or [, in order to specify + whether the range interval is exclusive or inclusive, respectively. + + ``offset`` and ``num`` are specified, then return a slice of the range. + Can't be provided when using ``bylex``. + """ + return self._zrange('ZRANGE', None, name, start, end, desc, byscore, + bylex, withscores, score_cast_func, offset, num) + + def zrevrange(self, name, start, end, withscores=False, + score_cast_func=float): + """ + Return a range of values from sorted set ``name`` between + ``start`` and ``end`` sorted in descending order. + + ``start`` and ``end`` can be negative, indicating the end of the range. + + ``withscores`` indicates to return the scores along with the values The return type is a list of (value, score) pairs ``score_cast_func`` a callable used to cast the score return value """ - if desc: - return self.zrevrange(name, start, end, withscores, - score_cast_func) - pieces = ['ZRANGE', name, start, end] - if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } - return self.execute_command(*pieces, **options) + return self.zrange(name, start, end, desc=True, + withscores=withscores, + score_cast_func=score_cast_func) - def zrangestore(self, dest, name, start, end): + def zrangestore(self, dest, name, start, end, + byscore=False, bylex=False, desc=False, + offset=None, num=None): """ Stores in ``dest`` the result of a range of values from sorted set ``name`` between ``start`` and ``end`` sorted in ascending order. ``start`` and ``end`` can be negative, indicating the end of the range. + + ``byscore`` when set to True, returns the range of elements from the + sorted set having scores equal or between ``start`` and ``end``. + + ``bylex`` when set to True, returns the range of elements from the + sorted set between the ``start`` and ``end`` lexicographical closed + range intervals. + Valid ``start`` and ``end`` must start with ( or [, in order to specify + whether the range interval is exclusive or inclusive, respectively. + + ``desc`` a boolean indicating whether to sort the results in reversed + order. + + ``offset`` and ``num`` are specified, then return a slice of the range. + Can't be provided when using ``bylex``. """ - return self.execute_command('ZRANGESTORE', dest, name, start, end) + return self._zrange('ZRANGESTORE', dest, name, start, end, desc, + byscore, bylex, False, None, offset, num) def zrangebylex(self, name, min, max, start=None, num=None): """ @@ -2484,13 +2550,7 @@ def zrangebylex(self, name, min, max, start=None, num=None): If ``start`` and ``num`` are specified, then return a slice of the range. """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZRANGEBYLEX', name, min, max] - if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) - return self.execute_command(*pieces) + return self.zrange(name, min, max, bylex=True, offset=start, num=num) def zrevrangebylex(self, name, max, min, start=None, num=None): """ @@ -2500,13 +2560,8 @@ def zrevrangebylex(self, name, max, min, start=None, num=None): If ``start`` and ``num`` are specified, then return a slice of the range. """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZREVRANGEBYLEX', name, max, min] - if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) - return self.execute_command(*pieces) + return self.zrange(name, max, min, desc=True, + bylex=True, offset=start, num=num) def zrangebyscore(self, name, min, max, start=None, num=None, withscores=False, score_cast_func=float): @@ -2522,19 +2577,29 @@ def zrangebyscore(self, name, min, max, start=None, num=None, `score_cast_func`` a callable used to cast the score return value """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZRANGEBYSCORE', name, min, max] - if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) - if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } - return self.execute_command(*pieces, **options) + return self.zrange(name, min, max, byscore=True, + offset=start, num=num, + withscores=withscores, + score_cast_func=score_cast_func) + + def zrevrangebyscore(self, name, max, min, start=None, num=None, + withscores=False, score_cast_func=float): + """ + Return a range of values from the sorted set ``name`` with scores + between ``min`` and ``max`` in descending order. + + If ``start`` and ``num`` are specified, then return a slice + of the range. + + ``withscores`` indicates to return the scores along with the values. + The return type is a list of (value, score) pairs + + ``score_cast_func`` a callable used to cast the score return value + """ + return self.zrange(name, max, min, desc=True, + byscore=True, offset=start, + num=num, withscores=withscores, + score_cast_func=score_cast_func) def zrank(self, name, value): """ @@ -2572,56 +2637,6 @@ def zremrangebyscore(self, name, min, max): """ return self.execute_command('ZREMRANGEBYSCORE', name, min, max) - def zrevrange(self, name, start, end, withscores=False, - score_cast_func=float): - """ - Return a range of values from sorted set ``name`` between - ``start`` and ``end`` sorted in descending order. - - ``start`` and ``end`` can be negative, indicating the end of the range. - - ``withscores`` indicates to return the scores along with the values - The return type is a list of (value, score) pairs - - ``score_cast_func`` a callable used to cast the score return value - """ - pieces = ['ZREVRANGE', name, start, end] - if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } - return self.execute_command(*pieces, **options) - - def zrevrangebyscore(self, name, max, min, start=None, num=None, - withscores=False, score_cast_func=float): - """ - Return a range of values from the sorted set ``name`` with scores - between ``min`` and ``max`` in descending order. - - If ``start`` and ``num`` are specified, then return a slice - of the range. - - ``withscores`` indicates to return the scores along with the values. - The return type is a list of (value, score) pairs - - ``score_cast_func`` a callable used to cast the score return value - """ - if (start is not None and num is None) or \ - (num is not None and start is None): - raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZREVRANGEBYSCORE', name, max, min] - if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) - if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } - return self.execute_command(*pieces, **options) - def zrevrank(self, name, value): """ Returns a 0-based value indicating the descending rank of diff --git a/tests/test_commands.py b/tests/test_commands.py index 904e27fd56..8929198783 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1840,6 +1840,7 @@ def test_zrange(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) assert r.zrange('a', 0, 1) == [b'a1', b'a2'] assert r.zrange('a', 1, 2) == [b'a2', b'a3'] + assert r.zrange('a', 0, 2, desc=True) == [b'a3', b'a2', b'a1'] # withscores assert r.zrange('a', 0, 1, withscores=True) == \ @@ -1851,6 +1852,46 @@ def test_zrange(self, r): assert r.zrange('a', 0, 1, withscores=True, score_cast_func=int) == \ [(b'a1', 1), (b'a2', 2)] + def test_zrange_errors(self, r): + with pytest.raises(exceptions.DataError): + r.zrange('a', 0, 1, byscore=True, bylex=True) + with pytest.raises(exceptions.DataError): + r.zrange('a', 0, 1, bylex=True, withscores=True) + with pytest.raises(exceptions.DataError): + r.zrange('a', 0, 1, byscore=True, withscores=True, offset=4) + with pytest.raises(exceptions.DataError): + r.zrange('a', 0, 1, byscore=True, withscores=True, num=2) + + @skip_if_server_version_lt('6.2.0') + def test_zrange_params(self, r): + # bylex + r.zadd('a', {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0}) + assert r.zrange('a', '[aaa', '(g', bylex=True) == \ + [b'b', b'c', b'd', b'e', b'f'] + assert r.zrange('a', '[f', '+', bylex=True) == [b'f', b'g'] + assert r.zrange('a', '+', '[f', desc=True, bylex=True) == [b'g', b'f'] + assert r.zrange('a', '-', '+', bylex=True, offset=3, num=2) == \ + [b'd', b'e'] + assert r.zrange('a', '+', '-', desc=True, bylex=True, + offset=3, num=2) == \ + [b'd', b'c'] + + # byscore + r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) + assert r.zrange('a', 2, 4, byscore=True, offset=1, num=2) == \ + [b'a3', b'a4'] + assert r.zrange('a', 4, 2, desc=True, byscore=True, + offset=1, num=2) == \ + [b'a3', b'a2'] + assert r.zrange('a', 2, 4, byscore=True, withscores=True) == \ + [(b'a2', 2.0), (b'a3', 3.0), (b'a4', 4.0)] + assert r.zrange('a', 4, 2, desc=True, byscore=True, + withscores=True, score_cast_func=int) == \ + [(b'a4', 4), (b'a3', 3), (b'a2', 2)] + + # rev + assert r.zrange('a', 0, 1, desc=True) == [b'a5', b'a4'] + @skip_if_server_version_lt('6.2.0') def test_zrangestore(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) @@ -1860,6 +1901,16 @@ def test_zrangestore(self, r): assert r.zrange('b', 0, -1) == [b'a2', b'a3'] assert r.zrange('b', 0, -1, withscores=True) == \ [(b'a2', 2), (b'a3', 3)] + # reversed order + assert r.zrangestore('b', 'a', 1, 2, desc=True) + assert r.zrange('b', 0, -1) == [b'a1', b'a2'] + # by score + assert r.zrangestore('b', 'a', 1, 2, byscore=True, + offset=0, num=1) + assert r.zrange('b', 0, -1) == [b'a1'] + # by lex + assert r.zrange('a', '[a2', '(a3', bylex=True) == \ + [b'a2'] @skip_if_server_version_lt('2.8.9') def test_zrangebylex(self, r): @@ -1885,16 +1936,12 @@ def test_zrevrangebylex(self, r): def test_zrangebyscore(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) assert r.zrangebyscore('a', 2, 4) == [b'a2', b'a3', b'a4'] - # slicing with start/num assert r.zrangebyscore('a', 2, 4, start=1, num=2) == \ [b'a3', b'a4'] - # withscores assert r.zrangebyscore('a', 2, 4, withscores=True) == \ [(b'a2', 2.0), (b'a3', 3.0), (b'a4', 4.0)] - - # custom score function assert r.zrangebyscore('a', 2, 4, withscores=True, score_cast_func=int) == \ [(b'a2', 2), (b'a3', 3), (b'a4', 4)] @@ -1958,15 +2005,12 @@ def test_zrevrange(self, r): def test_zrevrangebyscore(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) assert r.zrevrangebyscore('a', 4, 2) == [b'a4', b'a3', b'a2'] - # slicing with start/num assert r.zrevrangebyscore('a', 4, 2, start=1, num=2) == \ [b'a3', b'a2'] - # withscores assert r.zrevrangebyscore('a', 4, 2, withscores=True) == \ [(b'a4', 4.0), (b'a3', 3.0), (b'a2', 2.0)] - # custom score function assert r.zrevrangebyscore('a', 4, 2, withscores=True, score_cast_func=int) == \ From 55ea5fb2051d7c80ae86766758058502f50ba23c Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Mon, 18 Oct 2021 04:03:21 -0400 Subject: [PATCH 0190/1164] Fix docs for client_kill_filter (#1584) --- redis/commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index ee883bcc74..2eb3d1052d 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -310,10 +310,10 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, 'master', 'slave' or 'pubsub' :param addr: Kills a client by its 'address:port' :param skipme: If True, then the client calling the command - :param laddr: Kills a client by its 'local (bind) address:port' - :param user: Kills a client for a specific user name will not get killed even if it is identified by one of the filter options. If skipme is not provided, the server defaults to skipme=True + :param laddr: Kills a client by its 'local (bind) address:port' + :param user: Kills a client for a specific user name """ args = [] if _type is not None: From 46430c2b756df8e6ffe7b33ac52c987a6a9c8cce Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Mon, 18 Oct 2021 04:05:19 -0400 Subject: [PATCH 0191/1164] Fix grammar of get param in set command (#1588) --- redis/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/commands.py b/redis/commands.py index 2eb3d1052d..9bd57e8184 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1146,7 +1146,7 @@ def set(self, name, value, (Available since Redis 6.0) ``get`` if True, set the value at key ``name`` to ``value`` and return - the old value stored at key, or None when key did not exist. + the old value stored at key, or None if the key did not exist. (Available since Redis 6.2) ``exat`` sets an expire flag on key ``name`` for ``ex`` seconds, From 03a0c312699b3e130e8032a41a9999d709a035b9 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Mon, 18 Oct 2021 10:07:02 +0200 Subject: [PATCH 0192/1164] Add support to NX XX and CH to `GEOADD` (#1605) --- redis/commands.py | 26 ++++++++++++-- tests/test_commands.py | 80 ++++++++++++++++++++++++++++-------------- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 9bd57e8184..9dad8a59b8 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2904,17 +2904,39 @@ def register_script(self, script): return Script(self, script) # GEO COMMANDS - def geoadd(self, name, *values): + def geoadd(self, name, values, nx=False, xx=False, ch=False): """ Add the specified geospatial items to the specified key identified by the ``name`` argument. The Geospatial items are given as ordered members of the ``values`` argument, each item or place is formed by the triad longitude, latitude and name. + + Note: You can use ZREM to remove elements. + + ``nx`` forces ZADD to only create new elements and not to update + scores for elements that already exist. + + ``xx`` forces ZADD to only update scores of elements that already + exist. New elements will not be added. + + ``ch`` modifies the return value to be the numbers of elements changed. + Changed elements include new elements that were added and elements + whose scores changed. """ + if nx and xx: + raise DataError("GEOADD allows either 'nx' or 'xx', not both") if len(values) % 3 != 0: raise DataError("GEOADD requires places with lon, lat and name" " values") - return self.execute_command('GEOADD', name, *values) + pieces = [name] + if nx: + pieces.append('NX') + if xx: + pieces.append('XX') + if ch: + pieces.append('CH') + pieces.extend(values) + return self.execute_command('GEOADD', *pieces) def geodist(self, name, place1, place2, unit=None): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index 8929198783..802d8e448c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2422,35 +2422,63 @@ def test_readonly(self, mock_cluster_resp_ok): def test_geoadd(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - - assert r.geoadd('barcelona', *values) == 2 + assert r.geoadd('barcelona', values) == 2 assert r.zcard('barcelona') == 2 + @skip_if_server_version_lt('6.2.0') + def test_geoadd_nx(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + assert r.geoadd('a', values) == 2 + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + \ + (2.1804738294738, 41.405647879212, 'place3') + assert r.geoadd('a', values, nx=True) == 1 + assert r.zrange('a', 0, -1) == [b'place3', b'place2', b'place1'] + + @skip_if_server_version_lt('6.2.0') + def test_geoadd_xx(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + assert r.geoadd('a', values) == 1 + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + assert r.geoadd('a', values, xx=True) == 0 + assert r.zrange('a', 0, -1) == \ + [b'place1'] + + @skip_if_server_version_lt('6.2.0') + def test_geoadd_ch(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + assert r.geoadd('a', values) == 1 + values = (2.1909389952632, 31.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + assert r.geoadd('a', values, ch=True) == 2 + assert r.zrange('a', 0, -1) == \ + [b'place1', b'place2'] + @skip_if_server_version_lt('3.2.0') def test_geoadd_invalid_params(self, r): with pytest.raises(exceptions.RedisError): - r.geoadd('barcelona', *(1, 2)) + r.geoadd('barcelona', (1, 2)) @skip_if_server_version_lt('3.2.0') def test_geodist(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - - assert r.geoadd('barcelona', *values) == 2 + assert r.geoadd('barcelona', values) == 2 assert r.geodist('barcelona', 'place1', 'place2') == 3067.4157 @skip_if_server_version_lt('3.2.0') def test_geodist_units(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.geodist('barcelona', 'place1', 'place2', 'km') == 3.0674 @skip_if_server_version_lt('3.2.0') def test_geodist_missing_one_member(self, r): values = (2.1909389952632, 41.433791470673, 'place1') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.geodist('barcelona', 'place1', 'missing_member', 'km') is None @skip_if_server_version_lt('3.2.0') @@ -2462,8 +2490,7 @@ def test_geodist_invalid_units(self, r): def test_geohash(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.geohash('barcelona', 'place1', 'place2', 'place3') == \ ['sp3e9yg3kd0', 'sp3e9cbc3t0', None] @@ -2472,8 +2499,7 @@ def test_geohash(self, r): def test_geopos(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) # redis uses 52 bits precision, hereby small errors may be introduced. assert r.geopos('barcelona', 'place1', 'place2') == \ [(2.19093829393386841, 41.43379028184083523), @@ -2493,7 +2519,7 @@ def test_geosearch(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, b'\x80place2') + \ (2.583333, 41.316667, 'place3') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, radius=1000) == [b'place1'] assert r.geosearch('barcelona', longitude=2.187, @@ -2515,7 +2541,7 @@ def test_geosearch_member(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, b'\x80place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.geosearch('barcelona', member='place1', radius=4000) == \ [b'\x80place2', b'place1'] assert r.geosearch('barcelona', member='place1', radius=10) == \ @@ -2534,7 +2560,7 @@ def test_geosearch_member(self, r): def test_geosearch_sort(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, radius=3000, sort='ASC') == \ [b'place1', b'place2'] @@ -2547,7 +2573,7 @@ def test_geosearch_sort(self, r): def test_geosearch_with(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) # test a bunch of combinations to test the parse response # function. @@ -2618,7 +2644,7 @@ def test_geosearchstore(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) r.geosearchstore('places_barcelona', 'barcelona', longitude=2.191, latitude=41.433, radius=1000) assert r.zrange('places_barcelona', 0, -1) == [b'place1'] @@ -2629,7 +2655,7 @@ def test_geosearchstore_dist(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) r.geosearchstore('places_barcelona', 'barcelona', longitude=2.191, latitude=41.433, radius=1000, storedist=True) @@ -2641,7 +2667,7 @@ def test_georadius(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, b'\x80place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.georadius('barcelona', 2.191, 41.433, 1000) == [b'place1'] assert r.georadius('barcelona', 2.187, 41.406, 1000) == [b'\x80place2'] @@ -2650,7 +2676,7 @@ def test_georadius_no_values(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.georadius('barcelona', 1, 2, 1000) == [] @skip_if_server_version_lt('3.2.0') @@ -2658,7 +2684,7 @@ def test_georadius_units(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km') == \ [b'place1'] @@ -2668,7 +2694,7 @@ def test_georadius_with(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) # test a bunch of combinations to test the parse response # function. @@ -2696,7 +2722,7 @@ def test_georadius_count(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.georadius('barcelona', 2.191, 41.433, 3000, count=1) == \ [b'place1'] assert r.georadius('barcelona', 2.191, 41.433, 3000, @@ -2708,7 +2734,7 @@ def test_georadius_sort(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='ASC') == \ [b'place1', b'place2'] assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='DESC') == \ @@ -2719,7 +2745,7 @@ def test_georadius_store(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) r.georadius('barcelona', 2.191, 41.433, 1000, store='places_barcelona') assert r.zrange('places_barcelona', 0, -1) == [b'place1'] @@ -2729,7 +2755,7 @@ def test_georadius_store_dist(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) r.georadius('barcelona', 2.191, 41.433, 1000, store_dist='places_barcelona') # instead of save the geo score, the distance is saved. @@ -2741,7 +2767,7 @@ def test_georadiusmember(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, b'\x80place2') - r.geoadd('barcelona', *values) + r.geoadd('barcelona', values) assert r.georadiusbymember('barcelona', 'place1', 4000) == \ [b'\x80place2', b'place1'] assert r.georadiusbymember('barcelona', 'place1', 10) == [b'place1'] From cbbade97be48422cce0b203270a8e39a9bc5f519 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Mon, 18 Oct 2021 04:09:34 -0400 Subject: [PATCH 0193/1164] Update docs for multiple usernames for ACL DELUSER (#1595) --- redis/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/commands.py b/redis/commands.py index 9dad8a59b8..f20acfe2a0 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -49,7 +49,7 @@ def acl_cat(self, category=None): return self.execute_command('ACL CAT', *pieces) def acl_deluser(self, *username): - "Delete the ACL for the specified ``username``" + "Delete the ACL for the specified ``username``s" return self.execute_command('ACL DELUSER', *username) def acl_genpass(self, bits=None): From 87705815d31e5cf07b5d4b777eec0d2cf51aca1c Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Mon, 18 Oct 2021 04:16:29 -0400 Subject: [PATCH 0194/1164] Normalize minid and maxlen docs (#1593) Co-authored-by: Chayim --- redis/commands.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index f20acfe2a0..69aae207f0 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1820,16 +1820,16 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True, fields: dict of field/value pairs to insert into the stream id: Location to insert this record. By default it is appended. maxlen: truncate old stream members beyond this size. - Can't be specify with minid. - minid: the minimum id in the stream to query. - Can't be specify with maxlen. + Can't be specified with minid. approximate: actual stream length may be slightly more than maxlen nomkstream: When set to true, do not make a stream + minid: the minimum id in the stream to query. + Can't be specified with maxlen. limit: specifies the maximum number of entries to retrieve """ pieces = [] if maxlen is not None and minid is not None: - raise DataError("Only one of ```maxlen``` or ```minid```", + raise DataError("Only one of ```maxlen``` or ```minid``` " "may be specified") if maxlen is not None: @@ -2201,8 +2201,10 @@ def xtrim(self, name, maxlen=None, approximate=True, minid=None, Trims old messages from a stream. name: name of the stream. maxlen: truncate old stream messages beyond this size + Can't be specified with minid. approximate: actual stream length may be slightly more than maxlen minid: the minimum id in the stream to query + Can't be specified with maxlen. limit: specifies the maximum number of entries to retrieve """ pieces = [] From 726eedeae75e51c18bdb2ba697ff390a1efb2b78 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Mon, 18 Oct 2021 04:18:30 -0400 Subject: [PATCH 0195/1164] Fix client_kill_filter docs for skimpy (#1596) Co-authored-by: Chayim From 576d33c148de867fd48077a55d51135d14e45ec0 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 18 Oct 2021 12:08:54 +0300 Subject: [PATCH 0196/1164] REPLICAOF command implementation (#1622) --- redis/commands.py | 10 ++++++++++ redis/features.py | 5 +++++ tests/test_commands.py | 8 ++++++++ 3 files changed, 23 insertions(+) create mode 100644 redis/features.py diff --git a/redis/commands.py b/redis/commands.py index 69aae207f0..c2d9ff7bb1 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2845,6 +2845,16 @@ def pubsub_numsub(self, *args): def cluster(self, cluster_arg, *args): return self.execute_command('CLUSTER %s' % cluster_arg.upper(), *args) + def replicaof(self, *args): + """ + Update the replication settings of a redis replica, on the fly. + Examples of valid arguments include: + NO ONE (set no replication) + host port (set to the host and port of a redis server) + see: https://redis.io/commands/replicaof + """ + return self.execute_command('REPLICAOF', *args) + def eval(self, script, numkeys, *keys_and_args): """ Execute the Lua ``script``, specifying the ``numkeys`` the script diff --git a/redis/features.py b/redis/features.py new file mode 100644 index 0000000000..a96bac7c77 --- /dev/null +++ b/redis/features.py @@ -0,0 +1,5 @@ +try: + import hiredis # noqa + HIREDIS_AVAILABLE = True +except ImportError: + HIREDIS_AVAILABLE = False diff --git a/tests/test_commands.py b/tests/test_commands.py index 802d8e448c..2136d37caf 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3613,6 +3613,14 @@ def test_restore(self, r): assert r.restore(key, 0, dumpdata, frequency=5) assert r.get(key) == b'blee!' + @skip_if_server_version_lt('5.0.0') + def test_replicaof(self, r): + + with pytest.raises(redis.ResponseError): + assert r.replicaof("NO ONE") + + assert r.replicaof("NO", "ONE") + class TestBinarySave: From 7f96435480e74460989ad4dd4a686aff47e6b703 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 18 Oct 2021 12:19:37 +0300 Subject: [PATCH 0197/1164] CLIENT REDIR command support (#1623) --- redis/client.py | 1 + redis/commands.py | 8 ++++++++ tests/test_commands.py | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/redis/client.py b/redis/client.py index 2e2a26a2ad..fde153d26c 100755 --- a/redis/client.py +++ b/redis/client.py @@ -675,6 +675,7 @@ class Redis(Commands, object): 'CLIENT SETNAME': bool_ok, 'CLIENT UNBLOCK': lambda r: r and int(r) == 1 or False, 'CLIENT PAUSE': bool_ok, + 'CLIENT GETREDIR': int, 'CLIENT TRACKINGINFO': lambda r: list(map(str_if_bytes, r)), 'CLUSTER ADDSLOTS': bool_ok, 'CLUSTER COUNT-FAILURE-REPORTS': lambda x: int(x), diff --git a/redis/commands.py b/redis/commands.py index c2d9ff7bb1..cd3b802dca 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -377,6 +377,14 @@ def client_getname(self): """Returns the current connection name""" return self.execute_command('CLIENT GETNAME') + def client_getredir(self): + """Returns the ID (an integer) of the client to whom we are + redirecting tracking notifications. + + see: https://redis.io/commands/client-getredir + """ + return self.execute_command('CLIENT GETREDIR') + def client_reply(self, reply): """Enable and disable redis server replies. ``reply`` Must be ON OFF or SKIP, diff --git a/tests/test_commands.py b/tests/test_commands.py index 2136d37caf..62d42dbcc1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -490,6 +490,11 @@ def test_client_reply(self, r, r_timeout): # validate it was set assert r.get('foo') == b'bar' + @skip_if_server_version_lt('6.0.0') + def test_client_getredir(self, r): + assert isinstance(r.client_getredir(), int) + assert r.client_getredir() == -1 + def test_config_get(self, r): data = r.config_get() assert 'maxmemory' in data From 039488d97ec545b37e903d1b791a88bac8f77973 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 18 Oct 2021 12:30:13 +0300 Subject: [PATCH 0198/1164] Raising NotImplementedError for SCRIPT DEBUG and DEBUG SEGFAULT (#1624) --- redis/commands.py | 10 ++++++++++ tests/test_commands.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/redis/commands.py b/redis/commands.py index cd3b802dca..62c082d353 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -485,6 +485,11 @@ def debug_object(self, key): """Returns version specific meta information about a given key""" return self.execute_command('DEBUG OBJECT', key) + def debug_segfault(self): + raise NotImplementedError( + "DEBUG SEGFAULT is intentionally not implemented in the client." + ) + def echo(self, value): """Echo the string back from the server""" return self.execute_command('ECHO', value) @@ -2894,6 +2899,11 @@ def script_exists(self, *args): """ return self.execute_command('SCRIPT EXISTS', *args) + def script_debug(self, *args): + raise NotImplementedError( + "SCRIPT DEBUG is intentionally not implemented in the client." + ) + def script_flush(self, sync_type="SYNC"): """Flush all scripts from the script cache. ``sync_type`` is by default SYNC (synchronous) but it can also be diff --git a/tests/test_commands.py b/tests/test_commands.py index 62d42dbcc1..a7a3ce4279 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1629,6 +1629,16 @@ def test_sunionstore(self, r): assert r.sunionstore('c', 'a', 'b') == 3 assert r.smembers('c') == {b'1', b'2', b'3'} + @skip_if_server_version_lt('1.0.0') + def test_debug_segfault(self, r): + with pytest.raises(NotImplementedError): + r.debug_segfault() + + @skip_if_server_version_lt('3.2.0') + def test_script_debug(self, r): + with pytest.raises(NotImplementedError): + r.script_debug() + # SORTED SET COMMANDS def test_zadd(self, r): mapping = {'a1': 1.0, 'a2': 2.0, 'a3': 3.0} @@ -3535,6 +3545,16 @@ def test_bitfield_operations(self, r): .execute()) assert resp == [0, None, 255] + @skip_if_server_version_lt('4.0.0') + def test_memory_help(self, r): + with pytest.raises(NotImplementedError): + r.memory_help() + + @skip_if_server_version_lt('4.0.0') + def test_memory_doctor(self, r): + with pytest.raises(NotImplementedError): + r.memory_doctor() + @skip_if_server_version_lt('4.0.0') def test_memory_malloc_stats(self, r): assert r.memory_malloc_stats() From 39814846b765b1bba33cd7520b6462a9816c9d4a Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Tue, 19 Oct 2021 12:04:46 +0200 Subject: [PATCH 0199/1164] Add support to consumername in `xpending_range` (#1602) --- redis/commands.py | 14 ++++++++++---- tests/test_commands.py | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 62c082d353..44a77354aa 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2065,18 +2065,21 @@ def xpending(self, name, groupname): """ return self.execute_command('XPENDING', name, groupname) - def xpending_range(self, name, groupname, min, max, count, - consumername=None, idle=None): + def xpending_range(self, name, groupname, idle=None, + min=None, max=None, count=None, + consumername=None): """ Returns information about pending messages, in a range. + name: name of the stream. groupname: name of the consumer group. + idle: available from version 6.2. filter entries by their + idle-time, given in milliseconds (optional). min: minimum stream ID. max: maximum stream ID. count: number of messages to return consumername: name of a consumer to filter by (optional). - idle: available from version 6.2. filter entries by their - idle-time, given in milliseconds (optional). + """ if {min, max, count} == {None}: if idle is not None or consumername is not None: @@ -2103,6 +2106,9 @@ def xpending_range(self, name, groupname, min, max, count, pieces.extend([min, max, count]) except TypeError: pass + # consumername + if consumername: + pieces.append(consumername) return self.execute_command('XPENDING', *pieces, parse_detail=True) diff --git a/tests/test_commands.py b/tests/test_commands.py index a7a3ce4279..b7fa6bf059 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3217,6 +3217,14 @@ def test_xpending_range(self, r): assert response[1]['message_id'] == m2 assert response[1]['consumer'] == consumer2.encode() + # test with consumer name + response = r.xpending_range(stream, group, + min='-', max='+', count=5, + consumername=consumer1) + assert len(response) == 1 + assert response[0]['message_id'] == m1 + assert response[0]['consumer'] == consumer1.encode() + @skip_if_server_version_lt('6.2.0') def test_xpending_range_idle(self, r): stream = 'stream' From 2d18a05708523b1961daac4dd9647e2fc65367f1 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 19 Oct 2021 13:05:10 +0300 Subject: [PATCH 0200/1164] Removing packaging dependency (#1626) --- redis/connection.py | 10 +++++----- tests/conftest.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/redis/connection.py b/redis/connection.py index 5528589026..c2fb84f205 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -1,4 +1,4 @@ -from packaging.version import Version +from distutils.version import LooseVersion from itertools import chain from time import time from queue import LifoQueue, Empty, Full @@ -54,13 +54,13 @@ if HIREDIS_AVAILABLE: import hiredis - hiredis_version = Version(hiredis.__version__) + hiredis_version = LooseVersion(hiredis.__version__) HIREDIS_SUPPORTS_CALLABLE_ERRORS = \ - hiredis_version >= Version('0.1.3') + hiredis_version >= LooseVersion('0.1.3') HIREDIS_SUPPORTS_BYTE_BUFFER = \ - hiredis_version >= Version('0.1.4') + hiredis_version >= LooseVersion('0.1.4') HIREDIS_SUPPORTS_ENCODING_ERRORS = \ - hiredis_version >= Version('1.0.0') + hiredis_version >= LooseVersion('1.0.0') if not HIREDIS_SUPPORTS_BYTE_BUFFER: msg = ("redis-py works best with hiredis >= 0.1.4. You're running " diff --git a/tests/conftest.py b/tests/conftest.py index 3dc3ea149e..c099463807 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest import random import redis -from packaging.version import Version +from distutils.version import LooseVersion from redis.connection import parse_url from unittest.mock import Mock from urllib.parse import urlparse @@ -38,7 +38,7 @@ def pytest_sessionstart(session): def skip_if_server_version_lt(min_version): redis_version = REDIS_INFO["version"] - check = Version(redis_version) < Version(min_version) + check = LooseVersion(redis_version) < LooseVersion(min_version) return pytest.mark.skipif( check, reason="Redis version required >= {}".format(min_version)) @@ -46,7 +46,7 @@ def skip_if_server_version_lt(min_version): def skip_if_server_version_gte(min_version): redis_version = REDIS_INFO["version"] - check = Version(redis_version) >= Version(min_version) + check = LooseVersion(redis_version) >= LooseVersion(min_version) return pytest.mark.skipif( check, reason="Redis version required < {}".format(min_version)) @@ -183,7 +183,7 @@ def wait_for_command(client, monitor, command): # if we find a command with our key before the command we're waiting # for, something went wrong redis_version = REDIS_INFO["version"] - if Version(redis_version) >= Version('5.0.0'): + if LooseVersion(redis_version) >= LooseVersion('5.0.0'): id_str = str(client.client_id()) else: id_str = '%08x' % random.randrange(2**32) From 16cfcc7fced84d2b53edf95af1c40b230b30fc3d Mon Sep 17 00:00:00 2001 From: adiamzn <92513900+adiamzn@users.noreply.github.com> Date: Tue, 19 Oct 2021 13:14:49 +0300 Subject: [PATCH 0201/1164] Add warning when hiredis not installed. Recommend installation. (#1621) --- redis/connection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/redis/connection.py b/redis/connection.py index c2fb84f205..c99c550ecd 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -71,6 +71,9 @@ # only use byte buffer if hiredis supports it if not HIREDIS_SUPPORTS_BYTE_BUFFER: HIREDIS_USE_BYTE_BUFFER = False +else: + msg = "redis-py works best with hiredis. Please consider installing" + warnings.warn(msg) SYM_STAR = b'*' SYM_DOLLAR = b'$' From e60d97e6f428f4c536324922ebbe7efcd2440b83 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Wed, 20 Oct 2021 09:10:08 -0400 Subject: [PATCH 0202/1164] geosearch test should use any=True (#1594) Co-authored-by: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> --- tests/test_commands.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index b7fa6bf059..ceb12361b7 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2547,7 +2547,7 @@ def test_geosearch(self, r): assert r.geosearch('barcelona', member='place3', radius=100, unit='km', count=2) == [b'place3', b'\x80place2'] assert r.geosearch('barcelona', member='place3', radius=100, - unit='km', count=1, any=1)[0] \ + unit='km', count=1, any=True)[0] \ in [b'place1', b'place3', b'\x80place2'] @skip_unless_arch_bits(64) @@ -2652,7 +2652,8 @@ def test_geosearch_negative(self, r): # use any without count with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', member='place3', radius=100, any=1) + assert r.geosearch('barcelona', member='place3', + radius=100, any=True) @skip_if_server_version_lt('6.2.0') def test_geosearchstore(self, r): From 63ebe693174a4e6ec314e48d12fcdf3f8401eec6 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 21 Oct 2021 08:55:32 +0300 Subject: [PATCH 0203/1164] tox integrations with invoke and docker (#1632) --- .github/workflows/integration.yaml | 56 ++++++++++- .gitignore | 1 + CONTRIBUTING.rst | 41 ++++---- Dockerfile | 9 -- Makefile | 14 --- dev_requirements.txt | 5 + docker-entry.sh | 19 ---- docker/{slave => replica}/Dockerfile | 0 docker/replica/redis.conf | 4 + docker/slave/redis.conf | 4 - tasks.py | 60 ++++++++++++ tox.ini | 138 +++++++++++++++++++++------ 12 files changed, 256 insertions(+), 95 deletions(-) delete mode 100644 Dockerfile delete mode 100644 Makefile create mode 100644 dev_requirements.txt delete mode 100755 docker-entry.sh rename docker/{slave => replica}/Dockerfile (100%) create mode 100644 docker/replica/redis.conf delete mode 100644 docker/slave/redis.conf create mode 100644 tasks.py diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index f08a2c238f..2618c336da 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -2,12 +2,62 @@ name: CI on: push: + paths-ignore: + - 'docs/**' + - '**/*.rst' + - '**/*.md' pull_request: + paths-ignore: + - 'docs/**' + - '**/*.rst' + - '**/*.md' jobs: - integration: + + lint: + name: Code linters + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: install python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: run code linters + run: | + pip install -r dev_requirements.txt + invoke linters + + run-tests: + runs-on: ubuntu-latest + strategy: + max-parallel: 6 + matrix: + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.7'] + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + name: Python ${{ matrix.python-version }} tests + steps: + - uses: actions/checkout@v2 + - name: install python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: run tests + run: | + pip install -r dev_requirements.txt + invoke tests + + build_package: + name: Validate building and installing the package runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: test - run: make test + - name: install python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: build and install + run: | + pip install invoke + invoke package diff --git a/.gitignore b/.gitignore index b59bcdf097..05c384652c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ vagrant/.vagrant env venv coverage.xml +.venv diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index edaa7b6773..ba1ddfc5cc 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -29,25 +29,36 @@ Here's how to get started with your code contribution: 1. Create your own fork of redis-py 2. Do the changes in your fork -3. If you need a development environment, run ``make dev`` -4. While developing, make sure the tests pass by running ``make test`` +3. Create a virtualenv and install the development dependencies from the dev_requirements.txt file: + a. python -m venv .venv + b. source .venv/bin/activate + c. pip install -r dev_requirements.txt +3. If you need a development environment, run ``invoke devenv`` +4. While developing, make sure the tests pass by running ``invoke tests`` 5. If you like the change and think the project could use it, send a pull request +To see what else is part of the automation, run ``invoke -l`` + The Development Environment --------------------------- -Running ``make dev`` will create a Docker-based development environment that starts the following containers: +Running ``invoke devenv`` installs the development dependencies specified in the dev_requirements.txt. It starts all of the dockers used by this project, and leaves them running. These can be easily cleaned up with ``invoke clean``. NOTE: it is assumed that the user running these tests, can execute docker and its various commands. * A master Redis node -* A slave Redis node +* A Redis replica node * Three sentinel Redis nodes -* A test container +* A multi-python docker, with your source code mounted in /data -The slave is a replica of the master node, using the `leader-follower replication `_ feature. +The replica node, is a replica of the master node, using the `leader-follower replication `_ feature. The sentinels monitor the master node in a `sentinel high-availability configuration `_. -Meanwhile, the `test` container hosts the code from your checkout of ``redis-py`` and allows running tests against many Python versions. +Testing +------- + +Each run of tox starts and stops the various dockers required. Sometimes things get stuck, an ``invoke clean`` can help. + +Continuous Integration uses these same wrappers to run all of these tests against multiple versions of python. Feel free to test your changes against all the python versions supported, as declared by the tox.ini file (eg: tox -e py39). If you have the various python versions on your desktop, you can run *tox* by itself, to test all supported versions. Alternatively, as your source code is mounted in the **lots-of-pythons** docker, you can start exploring from there, with all supported python versions! Docker Tips ^^^^^^^^^^^ @@ -56,17 +67,17 @@ Following are a few tips that can help you work with the Docker-based developmen To get a bash shell inside of a container: -``$ docker-compose run /bin/bash`` - -**Note**: The term "service" refers to the "services" defined in the ``docker-compose.yml`` file: "master", "slave", "sentinel_1", "sentinel_2", "sentinel_3", "test". +``$ docker run -it /bin/bash`` + +**Note**: The term "service" refers to the "services" defined in the ``tox.ini`` file at the top of the repo: "master", "replicaof", "sentinel_1", "sentinel_2", "sentinel_3". Containers run a minimal Debian image that probably lacks tools you want to use. To install packages, first get a bash session (see previous tip) and then run: ``$ apt update && apt install `` -You can see the combined logging output of all containers like this: +You can see the logging output of a containers like this: -``$ docker-compose logs`` +``$ docker logs -f `` The command `make test` runs all tests in all tested Python environments. To run the tests in a single environment, like Python 3.6, use a command like this: @@ -81,13 +92,11 @@ Our test suite uses ``pytest``. You can run a specific test suite against a spec Troubleshooting ^^^^^^^^^^^^^^^ If you get any errors when running ``make dev`` or ``make test``, make sure that you -are using supported versions of Docker and docker-compose. +are using supported versions of Docker. -The included Dockerfiles and docker-compose.yml file work with the following -versions of Docker and docker-compose: +Please try at least versions of Docker. * Docker 19.03.12 -* docker-compose 1.26.2 How to Report a Bug ------------------- diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 2ceb725d47..0000000000 --- a/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM fkrull/multi-python:latest - -RUN apt update \ - && apt install -y pypy pypy-dev pypy3-dev \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /redis-py - -COPY . /redis-py diff --git a/Makefile b/Makefile deleted file mode 100644 index 0d0964345a..0000000000 --- a/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -.PHONY: base clean dev test - -base: - docker build -t redis-py-base docker/base - -dev: base - docker-compose up -d --build - -test: dev - docker-compose run --rm test /redis-py/docker-entry.sh - -clean: - docker-compose stop - docker-compose rm -f diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000000..2648127712 --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,5 @@ +flake8>=3.9.2 +pytest==6.2.5 +tox==3.24.4 +tox-docker==3.1.0 +invoke==1.6.0 diff --git a/docker-entry.sh b/docker-entry.sh deleted file mode 100755 index dc119dcdd8..0000000000 --- a/docker-entry.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# This is the entrypoint for "make test". It invokes Tox. If running -# outside the CI environment, it disables uploading the coverage report to codecov - -set -eu - -REDIS_MASTER="${REDIS_MASTER_HOST}":"${REDIS_MASTER_PORT}" -echo "Testing against Redis Server: ${REDIS_MASTER}" - -# skip the "codecov" env if not running on Travis -if [ "${GITHUB_ACTIONS}" = true ] ; then - echo "Skipping codecov" - export TOX_SKIP_ENV="codecov" -fi - -# use the wait-for-it util to ensure the server is running before invoking Tox -util/wait-for-it.sh ${REDIS_MASTER} -- tox -- --redis-url=redis://"${REDIS_MASTER}"/9 - diff --git a/docker/slave/Dockerfile b/docker/replica/Dockerfile similarity index 100% rename from docker/slave/Dockerfile rename to docker/replica/Dockerfile diff --git a/docker/replica/redis.conf b/docker/replica/redis.conf new file mode 100644 index 0000000000..4a1dcd7e9d --- /dev/null +++ b/docker/replica/redis.conf @@ -0,0 +1,4 @@ +bind replica 127.0.0.1 +port 6380 +save "" +replicaof master 6379 diff --git a/docker/slave/redis.conf b/docker/slave/redis.conf deleted file mode 100644 index 629ac70c62..0000000000 --- a/docker/slave/redis.conf +++ /dev/null @@ -1,4 +0,0 @@ -bind slave 127.0.0.1 -port 6380 -save "" -slaveof master 6379 diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000000..15e983bbfd --- /dev/null +++ b/tasks.py @@ -0,0 +1,60 @@ +import os +import shutil +from invoke import task, run + +with open('tox.ini') as fp: + lines = fp.read().split("\n") + dockers = [line.split("=")[1].strip() for line in lines + if line.find("name") != -1] + + +@task +def devenv(c): + """Builds a development environment: downloads, and starts all dockers + specified in the tox.ini file. + """ + clean(c) + cmd = 'tox -e devenv' + for d in dockers: + cmd += " --docker-dont-stop={}".format(d) + print("Running: {}".format(cmd)) + run(cmd) + + +@task +def linters(c): + """Run code linters""" + run("flake8") + + +@task +def all_tests(c): + """Run all linters, and tests in redis-py. This assumes you have all + the python versions specified in the tox.ini file. + """ + linters(c) + tests(c) + + +@task +def tests(c): + """Run the redis-py test suite against the current python, + with and without hiredis. + """ + run("tox -e plain -e hiredis") + + +@task +def clean(c): + """Stop all dockers, and clean up the built binaries, if generated.""" + if os.path.isdir("build"): + shutil.rmtree("build") + if os.path.isdir("dist"): + shutil.rmtree("dist") + run("docker rm -f {}".format(' '.join(dockers))) + + +@task +def package(c): + """Create the python packages""" + run("python setup.py build install") diff --git a/tox.ini b/tox.ini index db7492d18e..bfe937fe9e 100644 --- a/tox.ini +++ b/tox.ini @@ -2,50 +2,128 @@ addopts = -s [tox] -minversion = 2.4 -envlist = {py35,py36,py37,py38,py39,pypy3}-{plain,hiredis}, flake8, covreport, codecov +minversion = 3.2.0 +requires = tox-docker +envlist = {py35,py36,py37,py38,py39,pypy3}-{plain,hiredis}, flake8 + +[docker:master] +name = master +image = redis:6.2-bullseye +ports = + 6379:6379/tcp +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',6379)) else False" +volumes = + bind:rw:{toxinidir}/docker/master/redis.conf:/usr/local/etc/redis/redis.conf + +[docker:replica] +name = replica +image = redis:6.2-bullseye +links = + master:master +ports = + 6380:6380/tcp +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',6380)) else False" +volumes = + bind:rw:{toxinidir}/docker/replica/redis.conf:/usr/local/etc/redis/redis.conf + +[docker:sentinel_1] +name = sentinel_1 +image = redis:6.2-bullseye +links = + master:master +ports = + 26379:26379/tcp +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',26379)) else False" +volumes = + bind:rw:{toxinidir}/docker/sentinel_1/sentinel.conf:/usr/local/etc/redis/sentinel.conf + +[docker:sentinel_2] +name = sentinel_2 +image = redis:6.2-bullseye +links = + master:master +ports = + 26380:26380/tcp +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',26380)) else False" +volumes = + bind:rw:{toxinidir}/docker/sentinel_2/sentinel.conf:/usr/local/etc/redis/sentinel.conf + +[docker:sentinel_3] +name = sentinel_3 +image = redis:6.2-bullseye +links = + master:master +ports = + 26381:26381/tcp +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',26381)) else False" +volumes = + bind:rw:{toxinidir}/docker/sentinel_3/sentinel.conf:/usr/local/etc/redis/sentinel.conf + +[docker:redismod] +name = redismod +image = redislabs/redismod:edge +ports = + 16379:16379/tcp +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',16379)) else False" + +[docker:lots-of-pythons] +name = lots-of-pythons +image = redisfab/lots-of-pythons +volumes = + bind:rw:{toxinidir}:/data [testenv] -deps = - coverage - pytest >= 2.7.0 +deps = -r {toxinidir}/dev_requirements.txt +docker = + master + replica + sentinel_1 + sentinel_2 + sentinel_3 + redismod extras = hiredis: hiredis commands = - {envpython} -b -m coverage run -p -m pytest -W always {posargs} - {envpython} -b -m coverage combine --append + pytest -W always {posargs} + +[testenv:devenv] +skipsdist = true +skip_install = true +deps = -r {toxinidir}/dev_requirements.txt +docker = + master + replica + sentinel_1 + sentinel_2 + sentinel_3 + redismod + lots-of-pythons +commands = echo [testenv:flake8] -basepython = python3.6 -deps = flake8 +deps_files = dev_requirements.txt commands = flake8 skipsdist = true skip_install = true -[testenv:pypy-plain] -basepython = pypy - -[testenv:pypy-hiredis] -basepython = pypy - [testenv:pypy3-plain] basepython = pypy3 [testenv:pypy3-hiredis] basepython = pypy3 -[testenv:codecov] -deps = codecov -commands = codecov -passenv = - REDIS_* - CI - CI_* - CODECOV_* - SHIPPABLE - GITHUB_* - VCS_* - -[testenv:covreport] -deps = coverage -commands = coverage report +#[testenv:codecov] +#deps = codecov +#commands = codecov +#passenv = +# REDIS_* +# CI +# CI_* +# CODECOV_* +# SHIPPABLE +# GITHUB_* +# VCS_* +# +#[testenv:covreport] +#deps = coverage +#commands = coverage report From 638164b15840eddc0c58ae62d3e384b218d31c58 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 21 Oct 2021 12:08:24 +0200 Subject: [PATCH 0204/1164] Test BYLEX param in zrangestore (#1634) --- tests/test_commands.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index ceb12361b7..faa1e92b5b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1920,12 +1920,13 @@ def test_zrangestore(self, r): assert r.zrangestore('b', 'a', 1, 2, desc=True) assert r.zrange('b', 0, -1) == [b'a1', b'a2'] # by score - assert r.zrangestore('b', 'a', 1, 2, byscore=True, - offset=0, num=1) - assert r.zrange('b', 0, -1) == [b'a1'] + assert r.zrangestore('b', 'a', 2, 1, byscore=True, + offset=0, num=1, desc=True) + assert r.zrange('b', 0, -1) == [b'a2'] # by lex - assert r.zrange('a', '[a2', '(a3', bylex=True) == \ - [b'a2'] + assert r.zrangestore('b', 'a', '[a2', '(a3', bylex=True, + offset=0, num=1) + assert r.zrange('b', 0, -1) == [b'a2'] @skip_if_server_version_lt('2.8.9') def test_zrangebylex(self, r): From cf5c5865bb9947498f3810b028628f3d2ab14030 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 21 Oct 2021 16:23:33 +0200 Subject: [PATCH 0205/1164] Enable floating parameters in SET (ex and px) (#1635) --- redis/commands.py | 10 ++++++++-- tests/test_commands.py | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index 44a77354aa..f2c15384af 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -1174,12 +1174,18 @@ def set(self, name, value, pieces.append('EX') if isinstance(ex, datetime.timedelta): ex = int(ex.total_seconds()) - pieces.append(ex) + if isinstance(ex, int): + pieces.append(ex) + else: + raise DataError("ex must be datetime.timedelta or int") if px is not None: pieces.append('PX') if isinstance(px, datetime.timedelta): px = int(px.total_seconds() * 1000) - pieces.append(px) + if isinstance(px, int): + pieces.append(px) + else: + raise DataError("px must be datetime.timedelta or int") if exat is not None: pieces.append('EXAT') if isinstance(exat, datetime.datetime): diff --git a/tests/test_commands.py b/tests/test_commands.py index faa1e92b5b..fec453f4dc 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1104,6 +1104,8 @@ def test_set_px(self, r): assert r['a'] == b'1' assert 0 < r.pttl('a') <= 10000 assert 0 < r.ttl('a') <= 10 + with pytest.raises(exceptions.DataError): + assert r.set('a', '1', px=10.0) @skip_if_server_version_lt('2.6.0') def test_set_px_timedelta(self, r): @@ -1116,6 +1118,8 @@ def test_set_px_timedelta(self, r): def test_set_ex(self, r): assert r.set('a', '1', ex=10) assert 0 < r.ttl('a') <= 10 + with pytest.raises(exceptions.DataError): + assert r.set('a', '1', ex=10.0) @skip_if_server_version_lt('2.6.0') def test_set_ex_timedelta(self, r): From 36e00ec4e22a9f947e099affdfdc79862ac7ca08 Mon Sep 17 00:00:00 2001 From: Agustin Marquez Date: Mon, 25 Oct 2021 08:28:50 +0200 Subject: [PATCH 0206/1164] Add FULL option to XINFO SUMMARY (#1638) --- redis/client.py | 24 +++++++++++++++++------- redis/commands.py | 10 ++++++++-- tests/test_commands.py | 12 ++++++++++++ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/redis/client.py b/redis/client.py index fde153d26c..a6bd183bc6 100755 --- a/redis/client.py +++ b/redis/client.py @@ -308,14 +308,24 @@ def parse_xautoclaim(response, **options): return parse_stream_list(response[1]) -def parse_xinfo_stream(response): +def parse_xinfo_stream(response, **options): data = pairs_to_dict(response, decode_keys=True) - first = data['first-entry'] - if first is not None: - data['first-entry'] = (first[0], pairs_to_dict(first[1])) - last = data['last-entry'] - if last is not None: - data['last-entry'] = (last[0], pairs_to_dict(last[1])) + if not options.get('full', False): + first = data['first-entry'] + if first is not None: + data['first-entry'] = (first[0], pairs_to_dict(first[1])) + last = data['last-entry'] + if last is not None: + data['last-entry'] = (last[0], pairs_to_dict(last[1])) + else: + data['entries'] = { + _id: pairs_to_dict(entry) + for _id, entry in data['entries'] + } + data['groups'] = [ + pairs_to_dict(group, decode_keys=True) + for group in data['groups'] + ] return data diff --git a/redis/commands.py b/redis/commands.py index f2c15384af..f5243e45a9 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2050,12 +2050,18 @@ def xinfo_groups(self, name): """ return self.execute_command('XINFO GROUPS', name) - def xinfo_stream(self, name): + def xinfo_stream(self, name, full=False): """ Returns general information about the stream. name: name of the stream. + full: optional boolean, false by default. Return full summary """ - return self.execute_command('XINFO STREAM', name) + pieces = [name] + options = {} + if full: + pieces.append(b'FULL') + options = {'full': full} + return self.execute_command('XINFO STREAM', *pieces, **options) def xlen(self, name): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index fec453f4dc..694090ea7f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3156,6 +3156,18 @@ def test_xinfo_stream(self, r): assert info['first-entry'] == get_stream_message(r, stream, m1) assert info['last-entry'] == get_stream_message(r, stream, m2) + @skip_if_server_version_lt('6.0.0') + def test_xinfo_stream_full(self, r): + stream = 'stream' + group = 'group' + m1 = r.xadd(stream, {'foo': 'bar'}) + r.xgroup_create(stream, group, 0) + info = r.xinfo_stream(stream, full=True) + + assert info['length'] == 1 + assert m1 in info['entries'] + assert len(info['groups']) == 1 + @skip_if_server_version_lt('5.0.0') def test_xlen(self, r): stream = 'stream' From 0ef4c0711693b4b313ce97261214bd151d8261d5 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 25 Oct 2021 13:41:06 +0300 Subject: [PATCH 0207/1164] Pre 6.2 redis should default to None for script flush (#1641) --- redis/commands.py | 16 +++++++++++----- tests/test_scripting.py | 4 ++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/redis/commands.py b/redis/commands.py index f5243e45a9..2697e780a4 100644 --- a/redis/commands.py +++ b/redis/commands.py @@ -2922,16 +2922,22 @@ def script_debug(self, *args): "SCRIPT DEBUG is intentionally not implemented in the client." ) - def script_flush(self, sync_type="SYNC"): + def script_flush(self, sync_type=None): """Flush all scripts from the script cache. ``sync_type`` is by default SYNC (synchronous) but it can also be ASYNC. See: https://redis.io/commands/script-flush """ - if sync_type not in ["SYNC", "ASYNC"]: - raise DataError("SCRIPT FLUSH defaults to SYNC or " - "accepts SYNC/ASYNC") - pieces = [sync_type] + + # Redis pre 6 had no sync_type. + if sync_type not in ["SYNC", "ASYNC", None]: + raise DataError("SCRIPT FLUSH defaults to SYNC in redis > 6.2, or " + "accepts SYNC/ASYNC. For older versions, " + "of redis leave as None.") + if sync_type is None: + pieces = [] + else: + pieces = [sync_type] return self.execute_command('SCRIPT FLUSH', *pieces) def script_kill(self): diff --git a/tests/test_scripting.py b/tests/test_scripting.py index cc67e26678..c3c2094d4a 100644 --- a/tests/test_scripting.py +++ b/tests/test_scripting.py @@ -43,6 +43,10 @@ def test_script_flush(self, r): r.script_load(multiply_script) r.script_flush() + r.set('a', 2) + r.script_load(multiply_script) + r.script_flush(None) + with pytest.raises(exceptions.DataError): r.set('a', 2) r.script_load(multiply_script) From 3946da29d7e451a20289fb6e282516fa24e402af Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 25 Oct 2021 17:06:04 +0300 Subject: [PATCH 0208/1164] redisjson support (#1636) --- docker-compose.yml | 58 ------ docker/base/Dockerfile | 4 +- docker/base/Dockerfile.sentinel | 3 + docker/base/README.md | 1 + docker/master/Dockerfile | 7 - docker/master/redis.conf | 1 - docker/replica/Dockerfile | 7 - docker/replica/redis.conf | 1 - docker/sentinel_1/Dockerfile | 7 - docker/sentinel_1/sentinel.conf | 3 +- docker/sentinel_2/Dockerfile | 7 - docker/sentinel_2/sentinel.conf | 3 +- docker/sentinel_3/Dockerfile | 7 - docker/sentinel_3/sentinel.conf | 3 +- redis/client.py | 48 ++++- redis/commands/__init__.py | 11 ++ redis/{commands.py => commands/core.py} | 124 +------------ redis/commands/helpers.py | 25 +++ redis/commands/json/__init__.py | 84 +++++++++ redis/commands/json/commands.py | 197 ++++++++++++++++++++ redis/commands/json/helpers.py | 25 +++ redis/commands/json/path.py | 21 +++ redis/commands/redismodules.py | 17 ++ redis/commands/sentinel.py | 97 ++++++++++ tests/conftest.py | 33 ++++ tests/test_commands.py | 9 +- tests/test_connection.py | 33 +++- tests/test_json.py | 235 ++++++++++++++++++++++++ tox.ini | 28 +-- 29 files changed, 855 insertions(+), 244 deletions(-) delete mode 100644 docker-compose.yml create mode 100644 docker/base/Dockerfile.sentinel create mode 100644 docker/base/README.md delete mode 100644 docker/master/Dockerfile delete mode 100644 docker/replica/Dockerfile delete mode 100644 docker/sentinel_1/Dockerfile delete mode 100644 docker/sentinel_2/Dockerfile delete mode 100644 docker/sentinel_3/Dockerfile create mode 100644 redis/commands/__init__.py rename redis/{commands.py => commands/core.py} (96%) create mode 100644 redis/commands/helpers.py create mode 100644 redis/commands/json/__init__.py create mode 100644 redis/commands/json/commands.py create mode 100644 redis/commands/json/helpers.py create mode 100644 redis/commands/json/path.py create mode 100644 redis/commands/redismodules.py create mode 100644 redis/commands/sentinel.py create mode 100644 tests/test_json.py diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 103a51b19c..0000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,58 +0,0 @@ -version: "3.8" - -services: - master: - build: docker/master - ports: - - "6379:6379" - - slave: - build: docker/slave - depends_on: - - "master" - ports: - - "6380:6380" - - sentinel_1: - build: docker/sentinel_1 - depends_on: - - "slave" - ports: - - "26379:26379" - - sentinel_2: - build: docker/sentinel_2 - depends_on: - - "slave" - ports: - - "26380:26380" - - sentinel_3: - build: docker/sentinel_3 - depends_on: - - "slave" - ports: - - "26381:26381" - - test: - build: . - depends_on: - - "sentinel_3" - environment: - REDIS_MASTER_HOST: master - REDIS_MASTER_PORT: 6379 - CI: - CI_BUILD_ID: - CI_BUILD_URL: - CI_JOB_ID: - CODECOV_ENV: - CODECOV_SLUG: - CODECOV_TOKEN: - CODECOV_URL: - SHIPPABLE: - GITHUB_ACTIONS: - VCS_BRANCH_NAME: - VCS_COMMIT_ID: - VCS_PULL_REQUEST: - VCS_SLUG: - VCS_TAG: diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 60e4141e57..60be37483b 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1 +1,3 @@ -FROM redis:6.2.5-buster \ No newline at end of file +FROM redis:6.2.6-buster + +CMD ["redis-server", "/redis.conf"] diff --git a/docker/base/Dockerfile.sentinel b/docker/base/Dockerfile.sentinel new file mode 100644 index 0000000000..93c16a71ab --- /dev/null +++ b/docker/base/Dockerfile.sentinel @@ -0,0 +1,3 @@ +FROM redis:6.2.6-buster + +CMD ["redis-sentinel", "/sentinel.conf"] diff --git a/docker/base/README.md b/docker/base/README.md new file mode 100644 index 0000000000..a2f26a8106 --- /dev/null +++ b/docker/base/README.md @@ -0,0 +1 @@ +Dockers in this folder are built, and uploaded to the redisfab dockerhub store. diff --git a/docker/master/Dockerfile b/docker/master/Dockerfile deleted file mode 100644 index 1f4bd7391a..0000000000 --- a/docker/master/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM redis-py-base:latest - -COPY redis.conf /usr/local/etc/redis/redis.conf - -EXPOSE 6379 - -CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ] diff --git a/docker/master/redis.conf b/docker/master/redis.conf index ed007666b6..15a31b5a38 100644 --- a/docker/master/redis.conf +++ b/docker/master/redis.conf @@ -1,3 +1,2 @@ -bind master 127.0.0.1 port 6379 save "" diff --git a/docker/replica/Dockerfile b/docker/replica/Dockerfile deleted file mode 100644 index 7b6bdbe1b9..0000000000 --- a/docker/replica/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM redis-py-base:latest - -COPY redis.conf /usr/local/etc/redis/redis.conf - -EXPOSE 6380 - -CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ] diff --git a/docker/replica/redis.conf b/docker/replica/redis.conf index 4a1dcd7e9d..a76d402c5e 100644 --- a/docker/replica/redis.conf +++ b/docker/replica/redis.conf @@ -1,4 +1,3 @@ -bind replica 127.0.0.1 port 6380 save "" replicaof master 6379 diff --git a/docker/sentinel_1/Dockerfile b/docker/sentinel_1/Dockerfile deleted file mode 100644 index 66f6a75e5b..0000000000 --- a/docker/sentinel_1/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM redis-py-base:latest - -COPY sentinel.conf /usr/local/etc/redis/sentinel.conf - -EXPOSE 26379 - -CMD [ "redis-sentinel", "/usr/local/etc/redis/sentinel.conf" ] diff --git a/docker/sentinel_1/sentinel.conf b/docker/sentinel_1/sentinel.conf index fc9aa68c29..bd2d830af3 100644 --- a/docker/sentinel_1/sentinel.conf +++ b/docker/sentinel_1/sentinel.conf @@ -1,7 +1,6 @@ -bind sentinel_1 127.0.0.1 port 26379 -sentinel monitor redis-py-test master 6379 2 +sentinel monitor redis-py-test 127.0.0.1 6379 2 sentinel down-after-milliseconds redis-py-test 5000 sentinel failover-timeout redis-py-test 60000 sentinel parallel-syncs redis-py-test 1 diff --git a/docker/sentinel_2/Dockerfile b/docker/sentinel_2/Dockerfile deleted file mode 100644 index 1c0bb92e23..0000000000 --- a/docker/sentinel_2/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM redis-py-base:latest - -COPY sentinel.conf /usr/local/etc/redis/sentinel.conf - -EXPOSE 26380 - -CMD [ "redis-sentinel", "/usr/local/etc/redis/sentinel.conf" ] diff --git a/docker/sentinel_2/sentinel.conf b/docker/sentinel_2/sentinel.conf index 264443cbd4..955621b872 100644 --- a/docker/sentinel_2/sentinel.conf +++ b/docker/sentinel_2/sentinel.conf @@ -1,7 +1,6 @@ -bind sentinel_2 127.0.0.1 port 26380 -sentinel monitor redis-py-test master 6379 2 +sentinel monitor redis-py-test 127.0.0.1 6379 2 sentinel down-after-milliseconds redis-py-test 5000 sentinel failover-timeout redis-py-test 60000 sentinel parallel-syncs redis-py-test 1 diff --git a/docker/sentinel_3/Dockerfile b/docker/sentinel_3/Dockerfile deleted file mode 100644 index cf1ec21b70..0000000000 --- a/docker/sentinel_3/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM redis-py-base:latest - -COPY sentinel.conf /usr/local/etc/redis/sentinel.conf - -EXPOSE 26381 - -CMD [ "redis-sentinel", "/usr/local/etc/redis/sentinel.conf" ] diff --git a/docker/sentinel_3/sentinel.conf b/docker/sentinel_3/sentinel.conf index b0827f1f45..62c40512f1 100644 --- a/docker/sentinel_3/sentinel.conf +++ b/docker/sentinel_3/sentinel.conf @@ -1,7 +1,6 @@ -bind sentinel_3 127.0.0.1 port 26381 -sentinel monitor redis-py-test master 6379 2 +sentinel monitor redis-py-test 127.0.0.1 6379 2 sentinel down-after-milliseconds redis-py-test 5000 sentinel failover-timeout redis-py-test 60000 sentinel parallel-syncs redis-py-test 1 diff --git a/redis/client.py b/redis/client.py index a6bd183bc6..4db9887f4d 100755 --- a/redis/client.py +++ b/redis/client.py @@ -6,10 +6,7 @@ import threading import time import warnings -from redis.commands import ( - list_or_args, - Commands -) +from redis.commands import CoreCommands, RedisModuleCommands, list_or_args from redis.connection import (ConnectionPool, UnixDomainSocketConnection, SSLConnection) from redis.lock import Lock @@ -609,7 +606,7 @@ def parse_set_result(response, **options): return response and str_if_bytes(response) == 'OK' -class Redis(Commands, object): +class Redis(RedisModuleCommands, CoreCommands, object): """ Implementation of the Redis protocol. @@ -898,6 +895,47 @@ def set_response_callback(self, command, callback): "Set a custom Response Callback" self.response_callbacks[command] = callback + def load_external_module(self, modname, funcname, func): + """ + This function can be used to add externally defined redis modules, + and their namespaces to the redis client. + modname - A string containing the name of the redis module to look for + in the redis info block. + funcname - A string containing the name of the function to create + func - The function, being added to this class. + + ex: Assume that one has a custom redis module named foomod that + creates command named 'foo.dothing' and 'foo.anotherthing' in redis. + To load function functions into this namespace: + + from redis import Redis + from foomodule import F + r = Redis() + r.load_external_module("foomod", "foo", F) + r.foo().dothing('your', 'arguments') + + For a concrete example see the reimport of the redisjson module in + tests/test_connection.py::test_loading_external_modules + """ + mods = self.loaded_modules + if modname.lower() not in mods: + raise ModuleError("{} is not loaded in redis.".format(modname)) + setattr(self, funcname, func) + + @property + def loaded_modules(self): + key = '__redis_modules__' + mods = getattr(self, key, None) + if mods is not None: + return mods + + try: + mods = [f.get('name').lower() for f in self.info().get('modules')] + except TypeError: + mods = [] + setattr(self, key, mods) + return mods + def pipeline(self, transaction=True, shard_hint=None): """ Return a new pipeline object that can queue multiple commands for diff --git a/redis/commands/__init__.py b/redis/commands/__init__.py new file mode 100644 index 0000000000..f1ddaaabc1 --- /dev/null +++ b/redis/commands/__init__.py @@ -0,0 +1,11 @@ +from .core import CoreCommands +from .redismodules import RedisModuleCommands +from .helpers import list_or_args +from .sentinel import SentinelCommands + +__all__ = [ + 'CoreCommands', + 'RedisModuleCommands', + 'SentinelCommands', + 'list_or_args' +] diff --git a/redis/commands.py b/redis/commands/core.py similarity index 96% rename from redis/commands.py rename to redis/commands/core.py index 2697e780a4..6512b45a42 100644 --- a/redis/commands.py +++ b/redis/commands/core.py @@ -3,6 +3,7 @@ import warnings import hashlib +from .helpers import list_or_args from redis.exceptions import ( ConnectionError, DataError, @@ -11,24 +12,7 @@ ) -def list_or_args(keys, args): - # returns a single new list combining keys and args - try: - iter(keys) - # a string or bytes instance can be iterated, but indicates - # keys wasn't passed as a list - if isinstance(keys, (bytes, str)): - keys = [keys] - else: - keys = list(keys) - except TypeError: - keys = [keys] - if args: - keys.extend(args) - return keys - - -class Commands: +class CoreCommands: """ A class containing all of the implemented redis commands. This class is to be used as a mixin. @@ -1173,16 +1157,16 @@ def set(self, name, value, if ex is not None: pieces.append('EX') if isinstance(ex, datetime.timedelta): - ex = int(ex.total_seconds()) - if isinstance(ex, int): + pieces.append(int(ex.total_seconds())) + elif isinstance(ex, int): pieces.append(ex) else: raise DataError("ex must be datetime.timedelta or int") if px is not None: pieces.append('PX') if isinstance(px, datetime.timedelta): - px = int(px.total_seconds() * 1000) - if isinstance(px, int): + pieces.append(int(px.total_seconds() * 1000)) + elif isinstance(px, int): pieces.append(px) else: raise DataError("px must be datetime.timedelta or int") @@ -3413,99 +3397,3 @@ def execute(self): command = self.command self.reset() return self.client.execute_command(*command) - - -class SentinelCommands: - """ - A class containing the commands specific to redis sentinal. This class is - to be used as a mixin. - """ - - def sentinel(self, *args): - "Redis Sentinel's SENTINEL command." - warnings.warn( - DeprecationWarning('Use the individual sentinel_* methods')) - - def sentinel_get_master_addr_by_name(self, service_name): - "Returns a (host, port) pair for the given ``service_name``" - return self.execute_command('SENTINEL GET-MASTER-ADDR-BY-NAME', - service_name) - - def sentinel_master(self, service_name): - "Returns a dictionary containing the specified masters state." - return self.execute_command('SENTINEL MASTER', service_name) - - def sentinel_masters(self): - "Returns a list of dictionaries containing each master's state." - return self.execute_command('SENTINEL MASTERS') - - def sentinel_monitor(self, name, ip, port, quorum): - "Add a new master to Sentinel to be monitored" - return self.execute_command('SENTINEL MONITOR', name, ip, port, quorum) - - def sentinel_remove(self, name): - "Remove a master from Sentinel's monitoring" - return self.execute_command('SENTINEL REMOVE', name) - - def sentinel_sentinels(self, service_name): - "Returns a list of sentinels for ``service_name``" - return self.execute_command('SENTINEL SENTINELS', service_name) - - def sentinel_set(self, name, option, value): - "Set Sentinel monitoring parameters for a given master" - return self.execute_command('SENTINEL SET', name, option, value) - - def sentinel_slaves(self, service_name): - "Returns a list of slaves for ``service_name``" - return self.execute_command('SENTINEL SLAVES', service_name) - - def sentinel_reset(self, pattern): - """ - This command will reset all the masters with matching name. - The pattern argument is a glob-style pattern. - - The reset process clears any previous state in a master (including a - failover in progress), and removes every slave and sentinel already - discovered and associated with the master. - """ - return self.execute_command('SENTINEL RESET', pattern, once=True) - - def sentinel_failover(self, new_master_name): - """ - Force a failover as if the master was not reachable, and without - asking for agreement to other Sentinels (however a new version of the - configuration will be published so that the other Sentinels will - update their configurations). - """ - return self.execute_command('SENTINEL FAILOVER', new_master_name) - - def sentinel_ckquorum(self, new_master_name): - """ - Check if the current Sentinel configuration is able to reach the - quorum needed to failover a master, and the majority needed to - authorize the failover. - - This command should be used in monitoring systems to check if a - Sentinel deployment is ok. - """ - return self.execute_command('SENTINEL CKQUORUM', - new_master_name, - once=True) - - def sentinel_flushconfig(self): - """ - Force Sentinel to rewrite its configuration on disk, including the - current Sentinel state. - - Normally Sentinel rewrites the configuration every time something - changes in its state (in the context of the subset of the state which - is persisted on disk across restart). - However sometimes it is possible that the configuration file is lost - because of operation errors, disk failures, package upgrade scripts or - configuration managers. In those cases a way to to force Sentinel to - rewrite the configuration file is handy. - - This command works even if the previous configuration file is - completely missing. - """ - return self.execute_command('SENTINEL FLUSHCONFIG') diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py new file mode 100644 index 0000000000..b012621b3b --- /dev/null +++ b/redis/commands/helpers.py @@ -0,0 +1,25 @@ +def list_or_args(keys, args): + # returns a single new list combining keys and args + try: + iter(keys) + # a string or bytes instance can be iterated, but indicates + # keys wasn't passed as a list + if isinstance(keys, (bytes, str)): + keys = [keys] + else: + keys = list(keys) + except TypeError: + keys = [keys] + if args: + keys.extend(args) + return keys + + +def nativestr(x): + """Return the decoded binary string, or a string, depending on type.""" + return x.decode("utf-8", "replace") if isinstance(x, bytes) else x + + +def delist(x): + """Given a list of binaries, return the stringified version.""" + return [nativestr(obj) for obj in x] diff --git a/redis/commands/json/__init__.py b/redis/commands/json/__init__.py new file mode 100644 index 0000000000..92f119992f --- /dev/null +++ b/redis/commands/json/__init__.py @@ -0,0 +1,84 @@ +# from typing import Optional +from json import JSONDecoder, JSONEncoder + +# # from redis.client import Redis + +from .helpers import bulk_of_jsons +from ..helpers import nativestr, delist +from .commands import JSONCommands +# from ..feature import AbstractFeature + + +class JSON(JSONCommands): + """ + Create a client for talking to json. + + :param decoder: + :type json.JSONDecoder: An instance of json.JSONDecoder + + :param encoder: + :type json.JSONEncoder: An instance of json.JSONEncoder + """ + + def __init__( + self, + client, + decoder=JSONDecoder(), + encoder=JSONEncoder(), + ): + """ + Create a client for talking to json. + + :param decoder: + :type json.JSONDecoder: An instance of json.JSONDecoder + + :param encoder: + :type json.JSONEncoder: An instance of json.JSONEncoder + """ + # Set the module commands' callbacks + self.MODULE_CALLBACKS = { + "JSON.CLEAR": int, + "JSON.DEL": int, + "JSON.FORGET": int, + "JSON.GET": self._decode, + "JSON.MGET": bulk_of_jsons(self._decode), + "JSON.SET": lambda r: r and nativestr(r) == "OK", + "JSON.NUMINCRBY": self._decode, + "JSON.NUMMULTBY": self._decode, + "JSON.TOGGLE": lambda b: b == b"true", + "JSON.STRAPPEND": int, + "JSON.STRLEN": int, + "JSON.ARRAPPEND": int, + "JSON.ARRINDEX": int, + "JSON.ARRINSERT": int, + "JSON.ARRLEN": int, + "JSON.ARRPOP": self._decode, + "JSON.ARRTRIM": int, + "JSON.OBJLEN": int, + "JSON.OBJKEYS": delist, + # "JSON.RESP": delist, + "JSON.DEBUG": int, + } + + self.client = client + self.execute_command = client.execute_command + + for key, value in self.MODULE_CALLBACKS.items(): + self.client.set_response_callback(key, value) + + self.__encoder__ = encoder + self.__decoder__ = decoder + + def _decode(self, obj): + """Get the decoder.""" + if obj is None: + return obj + + try: + return self.__decoder__.decode(obj) + except TypeError: + return self.__decoder__.decode(obj.decode()) + + def _encode(self, obj): + """Get the encoder.""" + return self.__encoder__.encode(obj) diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py new file mode 100644 index 0000000000..2f8039f8bf --- /dev/null +++ b/redis/commands/json/commands.py @@ -0,0 +1,197 @@ +from .path import Path, str_path +from .helpers import decode_dict_keys + + +class JSONCommands: + """json commands.""" + + def arrappend(self, name, path=Path.rootPath(), *args): + """Append the objects ``args`` to the array under the + ``path` in key ``name``. + """ + pieces = [name, str_path(path)] + for o in args: + pieces.append(self._encode(o)) + return self.execute_command("JSON.ARRAPPEND", *pieces) + + def arrindex(self, name, path, scalar, start=0, stop=-1): + """ + Return the index of ``scalar`` in the JSON array under ``path`` at key + ``name``. + + The search can be limited using the optional inclusive ``start`` + and exclusive ``stop`` indices. + """ + return self.execute_command( + "JSON.ARRINDEX", name, str_path(path), self._encode(scalar), + start, stop + ) + + def arrinsert(self, name, path, index, *args): + """Insert the objects ``args`` to the array at index ``index`` + under the ``path` in key ``name``. + """ + pieces = [name, str_path(path), index] + for o in args: + pieces.append(self._encode(o)) + return self.execute_command("JSON.ARRINSERT", *pieces) + + def forget(self, name, path=Path.rootPath()): + """Alias for jsondel (delete the JSON value).""" + return self.execute_command("JSON.FORGET", name, str_path(path)) + + def arrlen(self, name, path=Path.rootPath()): + """Return the length of the array JSON value under ``path`` + at key``name``. + """ + return self.execute_command("JSON.ARRLEN", name, str_path(path)) + + def arrpop(self, name, path=Path.rootPath(), index=-1): + """Pop the element at ``index`` in the array JSON value under + ``path`` at key ``name``. + """ + return self.execute_command("JSON.ARRPOP", name, str_path(path), index) + + def arrtrim(self, name, path, start, stop): + """Trim the array JSON value under ``path`` at key ``name`` to the + inclusive range given by ``start`` and ``stop``. + """ + return self.execute_command("JSON.ARRTRIM", name, str_path(path), + start, stop) + + def type(self, name, path=Path.rootPath()): + """Get the type of the JSON value under ``path`` from key ``name``.""" + return self.execute_command("JSON.TYPE", name, str_path(path)) + + def resp(self, name, path=Path.rootPath()): + """Return the JSON value under ``path`` at key ``name``.""" + return self.execute_command("JSON.RESP", name, str_path(path)) + + def objkeys(self, name, path=Path.rootPath()): + """Return the key names in the dictionary JSON value under ``path`` at + key ``name``.""" + return self.execute_command("JSON.OBJKEYS", name, str_path(path)) + + def objlen(self, name, path=Path.rootPath()): + """Return the length of the dictionary JSON value under ``path`` at key + ``name``. + """ + return self.execute_command("JSON.OBJLEN", name, str_path(path)) + + def numincrby(self, name, path, number): + """Increment the numeric (integer or floating point) JSON value under + ``path`` at key ``name`` by the provided ``number``. + """ + return self.execute_command( + "JSON.NUMINCRBY", name, str_path(path), self._encode(number) + ) + + def nummultby(self, name, path, number): + """Multiply the numeric (integer or floating point) JSON value under + ``path`` at key ``name`` with the provided ``number``. + """ + return self.execute_command( + "JSON.NUMMULTBY", name, str_path(path), self._encode(number) + ) + + def clear(self, name, path=Path.rootPath()): + """ + Empty arrays and objects (to have zero slots/keys without deleting the + array/object). + + Return the count of cleared paths (ignoring non-array and non-objects + paths). + """ + return self.execute_command("JSON.CLEAR", name, str_path(path)) + + def delete(self, name, path=Path.rootPath()): + """Delete the JSON value stored at key ``name`` under ``path``.""" + return self.execute_command("JSON.DEL", name, str_path(path)) + + def get(self, name, *args, no_escape=False): + """ + Get the object stored as a JSON value at key ``name``. + + ``args`` is zero or more paths, and defaults to root path + ```no_escape`` is a boolean flag to add no_escape option to get + non-ascii characters + """ + pieces = [name] + if no_escape: + pieces.append("noescape") + + if len(args) == 0: + pieces.append(Path.rootPath()) + + else: + for p in args: + pieces.append(str_path(p)) + + # Handle case where key doesn't exist. The JSONDecoder would raise a + # TypeError exception since it can't decode None + try: + return self.execute_command("JSON.GET", *pieces) + except TypeError: + return None + + def mget(self, path, *args): + """Get the objects stored as a JSON values under ``path`` from keys + ``args``. + """ + pieces = [] + pieces.extend(args) + pieces.append(str_path(path)) + return self.execute_command("JSON.MGET", *pieces) + + def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): + """ + Set the JSON value at key ``name`` under the ``path`` to ``obj``. + + ``nx`` if set to True, set ``value`` only if it does not exist. + ``xx`` if set to True, set ``value`` only if it exists. + ``decode_keys`` If set to True, the keys of ``obj`` will be decoded + with utf-8. + """ + if decode_keys: + obj = decode_dict_keys(obj) + + pieces = [name, str_path(path), self._encode(obj)] + + # Handle existential modifiers + if nx and xx: + raise Exception( + "nx and xx are mutually exclusive: use one, the " + "other or neither - but not both" + ) + elif nx: + pieces.append("NX") + elif xx: + pieces.append("XX") + return self.execute_command("JSON.SET", *pieces) + + def strlen(self, name, path=Path.rootPath()): + """Return the length of the string JSON value under ``path`` at key + ``name``. + """ + return self.execute_command("JSON.STRLEN", name, str_path(path)) + + def toggle(self, name, path=Path.rootPath()): + """Toggle boolean value under ``path`` at key ``name``. + returning the new value. + """ + return self.execute_command("JSON.TOGGLE", name, str_path(path)) + + def strappend(self, name, string, path=Path.rootPath()): + """Append to the string JSON value under ``path`` at key ``name`` + the provided ``string``. + """ + return self.execute_command( + "JSON.STRAPPEND", name, str_path(path), self._encode(string) + ) + + def debug(self, name, path=Path.rootPath()): + """Return the memory usage in bytes of a value under ``path`` from + key ``name``. + """ + return self.execute_command("JSON.DEBUG", "MEMORY", + name, str_path(path)) diff --git a/redis/commands/json/helpers.py b/redis/commands/json/helpers.py new file mode 100644 index 0000000000..8fb20d9ac5 --- /dev/null +++ b/redis/commands/json/helpers.py @@ -0,0 +1,25 @@ +import copy + + +def bulk_of_jsons(d): + """Replace serialized JSON values with objects in a + bulk array response (list). + """ + + def _f(b): + for index, item in enumerate(b): + if item is not None: + b[index] = d(item) + return b + + return _f + + +def decode_dict_keys(obj): + """Decode the keys of the given dictionary with utf-8.""" + newobj = copy.copy(obj) + for k in obj.keys(): + if isinstance(k, bytes): + newobj[k.decode("utf-8")] = newobj[k] + newobj.pop(k) + return newobj diff --git a/redis/commands/json/path.py b/redis/commands/json/path.py new file mode 100644 index 0000000000..dff86482df --- /dev/null +++ b/redis/commands/json/path.py @@ -0,0 +1,21 @@ +def str_path(p): + """Return the string representation of a path if it is of class Path.""" + if isinstance(p, Path): + return p.strPath + else: + return p + + +class Path(object): + """This class represents a path in a JSON value.""" + + strPath = "" + + @staticmethod + def rootPath(): + """Return the root path's string representation.""" + return "." + + def __init__(self, path): + """Make a new path based on the string representation in `path`.""" + self.strPath = path diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py new file mode 100644 index 0000000000..fb53107130 --- /dev/null +++ b/redis/commands/redismodules.py @@ -0,0 +1,17 @@ +from json import JSONEncoder, JSONDecoder +from redis.exceptions import ModuleError + + +class RedisModuleCommands: + """This class contains the wrapper functions to bring supported redis + modules into the command namepsace. + """ + + def json(self, encoder=JSONEncoder(), decoder=JSONDecoder()): + """Access the json namespace, providing support for redis json.""" + if 'rejson' not in self.loaded_modules: + raise ModuleError("rejson is not a loaded in the redis instance.") + + from .json import JSON + jj = JSON(client=self, encoder=encoder, decoder=decoder) + return jj diff --git a/redis/commands/sentinel.py b/redis/commands/sentinel.py new file mode 100644 index 0000000000..1f02984bed --- /dev/null +++ b/redis/commands/sentinel.py @@ -0,0 +1,97 @@ +import warnings + + +class SentinelCommands: + """ + A class containing the commands specific to redis sentinal. This class is + to be used as a mixin. + """ + + def sentinel(self, *args): + "Redis Sentinel's SENTINEL command." + warnings.warn( + DeprecationWarning('Use the individual sentinel_* methods')) + + def sentinel_get_master_addr_by_name(self, service_name): + "Returns a (host, port) pair for the given ``service_name``" + return self.execute_command('SENTINEL GET-MASTER-ADDR-BY-NAME', + service_name) + + def sentinel_master(self, service_name): + "Returns a dictionary containing the specified masters state." + return self.execute_command('SENTINEL MASTER', service_name) + + def sentinel_masters(self): + "Returns a list of dictionaries containing each master's state." + return self.execute_command('SENTINEL MASTERS') + + def sentinel_monitor(self, name, ip, port, quorum): + "Add a new master to Sentinel to be monitored" + return self.execute_command('SENTINEL MONITOR', name, ip, port, quorum) + + def sentinel_remove(self, name): + "Remove a master from Sentinel's monitoring" + return self.execute_command('SENTINEL REMOVE', name) + + def sentinel_sentinels(self, service_name): + "Returns a list of sentinels for ``service_name``" + return self.execute_command('SENTINEL SENTINELS', service_name) + + def sentinel_set(self, name, option, value): + "Set Sentinel monitoring parameters for a given master" + return self.execute_command('SENTINEL SET', name, option, value) + + def sentinel_slaves(self, service_name): + "Returns a list of slaves for ``service_name``" + return self.execute_command('SENTINEL SLAVES', service_name) + + def sentinel_reset(self, pattern): + """ + This command will reset all the masters with matching name. + The pattern argument is a glob-style pattern. + + The reset process clears any previous state in a master (including a + failover in progress), and removes every slave and sentinel already + discovered and associated with the master. + """ + return self.execute_command('SENTINEL RESET', pattern, once=True) + + def sentinel_failover(self, new_master_name): + """ + Force a failover as if the master was not reachable, and without + asking for agreement to other Sentinels (however a new version of the + configuration will be published so that the other Sentinels will + update their configurations). + """ + return self.execute_command('SENTINEL FAILOVER', new_master_name) + + def sentinel_ckquorum(self, new_master_name): + """ + Check if the current Sentinel configuration is able to reach the + quorum needed to failover a master, and the majority needed to + authorize the failover. + + This command should be used in monitoring systems to check if a + Sentinel deployment is ok. + """ + return self.execute_command('SENTINEL CKQUORUM', + new_master_name, + once=True) + + def sentinel_flushconfig(self): + """ + Force Sentinel to rewrite its configuration on disk, including the + current Sentinel state. + + Normally Sentinel rewrites the configuration every time something + changes in its state (in the context of the subset of the state which + is persisted on disk across restart). + However sometimes it is possible that the configuration file is lost + because of operation errors, disk failures, package upgrade scripts or + configuration managers. In those cases a way to to force Sentinel to + rewrite the configuration file is handy. + + This command works even if the previous configuration file is + completely missing. + """ + return self.execute_command('SENTINEL FLUSHCONFIG') diff --git a/tests/conftest.py b/tests/conftest.py index c099463807..9ca429dafa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ REDIS_INFO = {} default_redis_url = "redis://localhost:6379/9" +default_redismod_url = "redis://localhost:36379/9" def pytest_addoption(parser): @@ -19,6 +20,12 @@ def pytest_addoption(parser): help="Redis connection string," " defaults to `%(default)s`") + parser.addoption('--redismod-url', default=default_redismod_url, + action="store", + help="Connection string to redis server" + " with loaded modules," + " defaults to `%(default)s`") + def _get_info(redis_url): client = redis.Redis.from_url(redis_url) @@ -35,6 +42,11 @@ def pytest_sessionstart(session): REDIS_INFO["version"] = version REDIS_INFO["arch_bits"] = arch_bits + # module info + redismod_url = session.config.getoption("--redismod-url") + info = _get_info(redismod_url) + REDIS_INFO["modules"] = info["modules"] + def skip_if_server_version_lt(min_version): redis_version = REDIS_INFO["version"] @@ -57,6 +69,21 @@ def skip_unless_arch_bits(arch_bits): reason="server is not {}-bit".format(arch_bits)) +def skip_ifmodversion_lt(min_version: str, module_name: str): + modules = REDIS_INFO["modules"] + if modules == []: + return pytest.mark.skipif(True, reason="No redis modules found") + + for j in modules: + if module_name == j.get('name'): + version = j.get('ver') + mv = int(min_version.replace(".", "")) + check = version < mv + return pytest.mark.skipif(check, reason="Redis module version") + + raise AttributeError("No redis module named {}".format(module_name)) + + def _get_client(cls, request, single_connection_client=True, flushdb=True, **kwargs): """ @@ -88,6 +115,12 @@ def teardown(): return client +@pytest.fixture() +def modclient(request, port=36379, **kwargs): + with _get_client(redis.Redis, request, port=port, **kwargs) as client: + yield client + + @pytest.fixture() def r(request): with _get_client(redis.Redis, request) as client: diff --git a/tests/test_commands.py b/tests/test_commands.py index 694090ea7f..6d65931539 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2552,7 +2552,7 @@ def test_geosearch(self, r): assert r.geosearch('barcelona', member='place3', radius=100, unit='km', count=2) == [b'place3', b'\x80place2'] assert r.geosearch('barcelona', member='place3', radius=100, - unit='km', count=1, any=True)[0] \ + unit='km', count=1, any=1)[0] \ in [b'place1', b'place3', b'\x80place2'] @skip_unless_arch_bits(64) @@ -2657,8 +2657,7 @@ def test_geosearch_negative(self, r): # use any without count with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', member='place3', - radius=100, any=True) + assert r.geosearch('barcelona', member='place3', radius=100, any=1) @skip_if_server_version_lt('6.2.0') def test_geosearchstore(self, r): @@ -3239,7 +3238,6 @@ def test_xpending_range(self, r): response = r.xpending_range(stream, group, min='-', max='+', count=5, consumername=consumer1) - assert len(response) == 1 assert response[0]['message_id'] == m1 assert response[0]['consumer'] == consumer1.encode() @@ -3604,7 +3602,8 @@ def test_memory_usage(self, r): @skip_if_server_version_lt('4.0.0') def test_module_list(self, r): assert isinstance(r.module_list(), list) - assert not r.module_list() + for x in r.module_list(): + assert isinstance(x, dict) @skip_if_server_version_lt('2.8.13') def test_command_count(self, r): diff --git a/tests/test_connection.py b/tests/test_connection.py index 128bac7d96..6728e0a05f 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,8 +1,10 @@ from unittest import mock +import types import pytest -from redis.exceptions import InvalidResponse +from redis.exceptions import InvalidResponse, ModuleError from redis.utils import HIREDIS_AVAILABLE +from .conftest import skip_if_server_version_lt @pytest.mark.skipif(HIREDIS_AVAILABLE, reason='PythonParser only') @@ -13,3 +15,32 @@ def test_invalid_response(r): with pytest.raises(InvalidResponse) as cm: parser.read_response() assert str(cm.value) == 'Protocol Error: %r' % raw + + +@skip_if_server_version_lt('4.0.0') +def test_loaded_modules(r, modclient): + assert r.loaded_modules == [] + assert 'rejson' in modclient.loaded_modules + + +@skip_if_server_version_lt('4.0.0') +def test_loading_external_modules(r, modclient): + def inner(): + pass + + with pytest.raises(ModuleError): + r.load_external_module('rejson', 'myfuncname', inner) + + modclient.load_external_module('rejson', 'myfuncname', inner) + assert getattr(modclient, 'myfuncname') == inner + assert isinstance(getattr(modclient, 'myfuncname'), types.FunctionType) + + # and call it + from redis.commands import RedisModuleCommands + j = RedisModuleCommands.json + modclient.load_external_module('rejson', 'sometestfuncname', j) + + d = {'hello': 'world!'} + mod = j(modclient) + mod.set("fookey", ".", d) + assert mod.get('fookey') == d diff --git a/tests/test_json.py b/tests/test_json.py new file mode 100644 index 0000000000..96675f1e45 --- /dev/null +++ b/tests/test_json.py @@ -0,0 +1,235 @@ +import pytest +import redis +from redis.commands.json.path import Path +from .conftest import skip_ifmodversion_lt + + +@pytest.fixture +def client(modclient): + modclient.flushdb() + return modclient + + +@pytest.mark.json +def test_json_setbinarykey(client): + d = {"hello": "world", b"some": "value"} + with pytest.raises(TypeError): + client.json().set("somekey", Path.rootPath(), d) + assert client.json().set("somekey", Path.rootPath(), d, decode_keys=True) + + +@pytest.mark.json +def test_json_setgetdeleteforget(client): + assert client.json().set("foo", Path.rootPath(), "bar") + assert client.json().get("foo") == "bar" + assert client.json().get("baz") is None + assert client.json().delete("foo") == 1 + assert client.json().forget("foo") == 0 # second delete + assert client.exists("foo") == 0 + + +@pytest.mark.json +def test_justaget(client): + client.json().set("foo", Path.rootPath(), "bar") + assert client.json().get("foo") == "bar" + + +@pytest.mark.json +def test_json_get_jset(client): + assert client.json().set("foo", Path.rootPath(), "bar") + assert "bar" == client.json().get("foo") + assert client.json().get("baz") is None + assert 1 == client.json().delete("foo") + assert client.exists("foo") == 0 + + +@pytest.mark.json +def test_nonascii_setgetdelete(client): + assert client.json().set("notascii", Path.rootPath(), + "hyvää-élève") is True + assert "hyvää-élève" == client.json().get("notascii", no_escape=True) + assert 1 == client.json().delete("notascii") + assert client.exists("notascii") == 0 + + +@pytest.mark.json +def test_jsonsetexistentialmodifiersshouldsucceed(client): + obj = {"foo": "bar"} + assert client.json().set("obj", Path.rootPath(), obj) + + # Test that flags prevent updates when conditions are unmet + assert client.json().set("obj", Path("foo"), "baz", nx=True) is None + assert client.json().set("obj", Path("qaz"), "baz", xx=True) is None + + # Test that flags allow updates when conditions are met + 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 + with pytest.raises(Exception): + client.json().set("obj", Path("foo"), "baz", nx=True, xx=True) + + +@pytest.mark.json +def test_mgetshouldsucceed(client): + client.json().set("1", Path.rootPath(), 1) + client.json().set("2", Path.rootPath(), 2) + r = client.json().mget(Path.rootPath(), "1", "2") + e = [1, 2] + assert e == r + + +@pytest.mark.json +@skip_ifmodversion_lt("99.99.99", "ReJSON") # todo: update after the release +def test_clearShouldSucceed(client): + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 1 == client.json().clear("arr", Path.rootPath()) + assert [] == client.json().get("arr") + + +@pytest.mark.json +def test_typeshouldsucceed(client): + client.json().set("1", Path.rootPath(), 1) + assert b"integer" == client.json().type("1") + + +@pytest.mark.json +def test_numincrbyshouldsucceed(client): + client.json().set("num", Path.rootPath(), 1) + assert 2 == client.json().numincrby("num", Path.rootPath(), 1) + assert 2.5 == client.json().numincrby("num", Path.rootPath(), 0.5) + assert 1.25 == client.json().numincrby("num", Path.rootPath(), -1.25) + + +@pytest.mark.json +def test_nummultbyshouldsucceed(client): + client.json().set("num", Path.rootPath(), 1) + assert 2 == client.json().nummultby("num", Path.rootPath(), 2) + assert 5 == client.json().nummultby("num", Path.rootPath(), 2.5) + assert 2.5 == client.json().nummultby("num", Path.rootPath(), 0.5) + + +@pytest.mark.json +@skip_ifmodversion_lt("99.99.99", "ReJSON") # todo: update after the release +def test_toggleShouldSucceed(client): + client.json().set("bool", Path.rootPath(), False) + assert client.json().toggle("bool", Path.rootPath()) + assert not client.json().toggle("bool", Path.rootPath()) + # check non-boolean value + client.json().set("num", Path.rootPath(), 1) + with pytest.raises(redis.exceptions.ResponseError): + client.json().toggle("num", Path.rootPath()) + + +@pytest.mark.json +def test_strappendshouldsucceed(client): + client.json().set("str", Path.rootPath(), "foo") + assert 6 == client.json().strappend("str", "bar", Path.rootPath()) + assert "foobar" == client.json().get("str", Path.rootPath()) + + +@pytest.mark.json +def test_debug(client): + client.json().set("str", Path.rootPath(), "foo") + assert 24 == client.json().debug("str", Path.rootPath()) + + +@pytest.mark.json +def test_strlenshouldsucceed(client): + client.json().set("str", Path.rootPath(), "foo") + assert 3 == client.json().strlen("str", Path.rootPath()) + client.json().strappend("str", "bar", Path.rootPath()) + assert 6 == client.json().strlen("str", Path.rootPath()) + + +@pytest.mark.json +def test_arrappendshouldsucceed(client): + client.json().set("arr", Path.rootPath(), [1]) + assert 2 == client.json().arrappend("arr", Path.rootPath(), 2) + assert 4 == client.json().arrappend("arr", Path.rootPath(), 3, 4) + assert 7 == client.json().arrappend("arr", Path.rootPath(), *[5, 6, 7]) + + +@pytest.mark.json +def testArrIndexShouldSucceed(client): + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 1 == client.json().arrindex("arr", Path.rootPath(), 1) + assert -1 == client.json().arrindex("arr", Path.rootPath(), 1, 2) + + +@pytest.mark.json +def test_arrinsertshouldsucceed(client): + client.json().set("arr", Path.rootPath(), [0, 4]) + assert 5 - -client.json().arrinsert( + "arr", + Path.rootPath(), + 1, + *[ + 1, + 2, + 3, + ] + ) + assert [0, 1, 2, 3, 4] == client.json().get("arr") + + +@pytest.mark.json +def test_arrlenshouldsucceed(client): + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 5 == client.json().arrlen("arr", Path.rootPath()) + + +@pytest.mark.json +def test_arrpopshouldsucceed(client): + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 4 == client.json().arrpop("arr", Path.rootPath(), 4) + assert 3 == client.json().arrpop("arr", Path.rootPath(), -1) + assert 2 == client.json().arrpop("arr", Path.rootPath()) + assert 0 == client.json().arrpop("arr", Path.rootPath(), 0) + assert [1] == client.json().get("arr") + + +@pytest.mark.json +def test_arrtrimshouldsucceed(client): + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 3 == client.json().arrtrim("arr", Path.rootPath(), 1, 3) + assert [1, 2, 3] == client.json().get("arr") + + +@pytest.mark.json +def test_respshouldsucceed(client): + obj = {"foo": "bar", "baz": 1, "qaz": True} + client.json().set("obj", Path.rootPath(), obj) + assert b"bar" == client.json().resp("obj", Path("foo")) + assert 1 == client.json().resp("obj", Path("baz")) + assert client.json().resp("obj", Path("qaz")) + + +@pytest.mark.json +def test_objkeysshouldsucceed(client): + obj = {"foo": "bar", "baz": "qaz"} + client.json().set("obj", Path.rootPath(), obj) + keys = client.json().objkeys("obj", Path.rootPath()) + keys.sort() + exp = list(obj.keys()) + exp.sort() + assert exp == keys + + +@pytest.mark.json +def test_objlenshouldsucceed(client): + obj = {"foo": "bar", "baz": "qaz"} + client.json().set("obj", Path.rootPath(), obj) + assert len(obj) == client.json().objlen("obj", Path.rootPath()) + + +# @pytest.mark.pipeline +# @pytest.mark.json +# def test_pipelineshouldsucceed(client): +# p = client.json().pipeline() +# p.set("foo", Path.rootPath(), "bar") +# p.get("foo") +# p.delete("foo") +# assert [True, "bar", 1] == p.execute() +# assert client.keys() == [] +# assert client.get("foo") is None diff --git a/tox.ini b/tox.ini index bfe937fe9e..78052963c6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,7 @@ [pytest] addopts = -s +markers = + json: run only the redisjson module tests [tox] minversion = 3.2.0 @@ -8,63 +10,63 @@ envlist = {py35,py36,py37,py38,py39,pypy3}-{plain,hiredis}, flake8 [docker:master] name = master -image = redis:6.2-bullseye +image = redisfab/redis-py:6.2.6-buster ports = 6379:6379/tcp healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',6379)) else False" volumes = - bind:rw:{toxinidir}/docker/master/redis.conf:/usr/local/etc/redis/redis.conf + bind:rw:{toxinidir}/docker/master/redis.conf:/redis.conf [docker:replica] name = replica -image = redis:6.2-bullseye +image = redisfab/redis-py:6.2.6-buster links = master:master ports = 6380:6380/tcp healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',6380)) else False" volumes = - bind:rw:{toxinidir}/docker/replica/redis.conf:/usr/local/etc/redis/redis.conf + bind:rw:{toxinidir}/docker/replica/redis.conf:/redis.conf [docker:sentinel_1] name = sentinel_1 -image = redis:6.2-bullseye +image = redisfab/redis-py-sentinel:6.2.6-buster links = master:master ports = 26379:26379/tcp healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',26379)) else False" volumes = - bind:rw:{toxinidir}/docker/sentinel_1/sentinel.conf:/usr/local/etc/redis/sentinel.conf + bind:rw:{toxinidir}/docker/sentinel_1/sentinel.conf:/sentinel.conf [docker:sentinel_2] name = sentinel_2 -image = redis:6.2-bullseye +image = redisfab/redis-py-sentinel:6.2.6-buster links = master:master ports = 26380:26380/tcp healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',26380)) else False" volumes = - bind:rw:{toxinidir}/docker/sentinel_2/sentinel.conf:/usr/local/etc/redis/sentinel.conf + bind:rw:{toxinidir}/docker/sentinel_2/sentinel.conf:/sentinel.conf [docker:sentinel_3] name = sentinel_3 -image = redis:6.2-bullseye +image = redisfab/redis-py-sentinel:6.2.6-buster links = master:master ports = 26381:26381/tcp healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',26381)) else False" volumes = - bind:rw:{toxinidir}/docker/sentinel_3/sentinel.conf:/usr/local/etc/redis/sentinel.conf + bind:rw:{toxinidir}/docker/sentinel_3/sentinel.conf:/sentinel.conf [docker:redismod] name = redismod image = redislabs/redismod:edge ports = - 16379:16379/tcp -healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',16379)) else False" + 36379:6379/tcp +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',36379)) else False" [docker:lots-of-pythons] name = lots-of-pythons @@ -98,7 +100,7 @@ docker = sentinel_3 redismod lots-of-pythons -commands = echo +commands = /usr/bin/echo [testenv:flake8] deps_files = dev_requirements.txt From ddd1496782cc8eb15fca6c9059b2b08a03efe366 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 25 Oct 2021 17:18:27 +0300 Subject: [PATCH 0209/1164] Adding support for redisearch (#1640) --- redis/commands/redismodules.py | 9 + redis/commands/search/__init__.py | 96 + redis/commands/search/_util.py | 10 + redis/commands/search/aggregation.py | 408 ++ redis/commands/search/commands.py | 704 ++++ redis/commands/search/document.py | 16 + redis/commands/search/field.py | 94 + redis/commands/search/indexDefinition.py | 80 + redis/commands/search/query.py | 328 ++ redis/commands/search/querystring.py | 324 ++ redis/commands/search/reducers.py | 178 + redis/commands/search/result.py | 75 + redis/commands/search/suggestion.py | 54 + tasks.py | 1 - tests/conftest.py | 15 +- tests/test_json.py | 50 +- tests/test_search.py | 1219 ++++++ tests/testdata/titles.csv | 4861 ++++++++++++++++++++++ tests/testdata/will_play_text.csv.bz2 | Bin 0 -> 2069623 bytes tox.ini | 2 +- 20 files changed, 8494 insertions(+), 30 deletions(-) create mode 100644 redis/commands/search/__init__.py create mode 100644 redis/commands/search/_util.py create mode 100644 redis/commands/search/aggregation.py create mode 100644 redis/commands/search/commands.py create mode 100644 redis/commands/search/document.py create mode 100644 redis/commands/search/field.py create mode 100644 redis/commands/search/indexDefinition.py create mode 100644 redis/commands/search/query.py create mode 100644 redis/commands/search/querystring.py create mode 100644 redis/commands/search/reducers.py create mode 100644 redis/commands/search/result.py create mode 100644 redis/commands/search/suggestion.py create mode 100644 tests/test_search.py create mode 100644 tests/testdata/titles.csv create mode 100755 tests/testdata/will_play_text.csv.bz2 diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py index fb53107130..2c9066a2e7 100644 --- a/redis/commands/redismodules.py +++ b/redis/commands/redismodules.py @@ -15,3 +15,12 @@ def json(self, encoder=JSONEncoder(), decoder=JSONDecoder()): from .json import JSON jj = JSON(client=self, encoder=encoder, decoder=decoder) return jj + + def ft(self, index_name="idx"): + """Access the search namespace, providing support for redis search.""" + if 'search' not in self.loaded_modules: + raise ModuleError("search is not a loaded in the redis instance.") + + from .search import Search + s = Search(client=self, index_name=index_name) + return s diff --git a/redis/commands/search/__init__.py b/redis/commands/search/__init__.py new file mode 100644 index 0000000000..8320ad4392 --- /dev/null +++ b/redis/commands/search/__init__.py @@ -0,0 +1,96 @@ +from .commands import SearchCommands + + +class Search(SearchCommands): + """ + Create a client for talking to search. + It abstracts the API of the module and lets you just use the engine. + """ + + class BatchIndexer(object): + """ + A batch indexer allows you to automatically batch + document indexing in pipelines, flushing it every N documents. + """ + + def __init__(self, client, chunk_size=1000): + + self.client = client + self.execute_command = client.execute_command + self.pipeline = client.pipeline(transaction=False, shard_hint=None) + self.total = 0 + self.chunk_size = chunk_size + self.current_chunk = 0 + + def __del__(self): + if self.current_chunk: + self.commit() + + def add_document( + self, + doc_id, + nosave=False, + score=1.0, + payload=None, + replace=False, + partial=False, + no_create=False, + **fields + ): + """ + Add a document to the batch query + """ + self.client._add_document( + doc_id, + conn=self.pipeline, + nosave=nosave, + score=score, + payload=payload, + replace=replace, + partial=partial, + no_create=no_create, + **fields + ) + self.current_chunk += 1 + self.total += 1 + if self.current_chunk >= self.chunk_size: + self.commit() + + def add_document_hash( + self, + doc_id, + score=1.0, + replace=False, + ): + """ + Add a hash to the batch query + """ + self.client._add_document_hash( + doc_id, + conn=self.pipeline, + score=score, + replace=replace, + ) + self.current_chunk += 1 + self.total += 1 + if self.current_chunk >= self.chunk_size: + self.commit() + + def commit(self): + """ + Manually commit and flush the batch indexing query + """ + self.pipeline.execute() + self.current_chunk = 0 + + def __init__(self, client, index_name="idx"): + """ + Create a new Client for the given index_name. + The default name is `idx` + + If conn is not None, we employ an already existing redis connection + """ + self.client = client + self.index_name = index_name + self.execute_command = client.execute_command + self.pipeline = client.pipeline diff --git a/redis/commands/search/_util.py b/redis/commands/search/_util.py new file mode 100644 index 0000000000..b4ac19f336 --- /dev/null +++ b/redis/commands/search/_util.py @@ -0,0 +1,10 @@ +import six + + +def to_string(s): + if isinstance(s, six.string_types): + return s + elif isinstance(s, six.binary_type): + return s.decode("utf-8", "ignore") + else: + return s # Not a string we care about diff --git a/redis/commands/search/aggregation.py b/redis/commands/search/aggregation.py new file mode 100644 index 0000000000..df912f896b --- /dev/null +++ b/redis/commands/search/aggregation.py @@ -0,0 +1,408 @@ +from six import string_types + +FIELDNAME = object() + + +class Limit(object): + def __init__(self, offset=0, count=0): + self.offset = offset + self.count = count + + def build_args(self): + if self.count: + return ["LIMIT", str(self.offset), str(self.count)] + else: + return [] + + +class Reducer(object): + """ + Base reducer object for all reducers. + + See the `redisearch.reducers` module for the actual reducers. + """ + + NAME = None + + def __init__(self, *args): + self._args = args + self._field = None + self._alias = None + + def alias(self, alias): + """ + Set the alias for this reducer. + + ### Parameters + + - **alias**: The value of the alias for this reducer. If this is the + special value `aggregation.FIELDNAME` then this reducer will be + aliased using the same name as the field upon which it operates. + Note that using `FIELDNAME` is only possible on reducers which + operate on a single field value. + + This method returns the `Reducer` object making it suitable for + chaining. + """ + if alias is FIELDNAME: + if not self._field: + raise ValueError("Cannot use FIELDNAME alias with no field") + # Chop off initial '@' + alias = self._field[1:] + self._alias = alias + return self + + @property + def args(self): + return self._args + + +class SortDirection(object): + """ + This special class is used to indicate sort direction. + """ + + DIRSTRING = None + + def __init__(self, field): + self.field = field + + +class Asc(SortDirection): + """ + Indicate that the given field should be sorted in ascending order + """ + + DIRSTRING = "ASC" + + +class Desc(SortDirection): + """ + Indicate that the given field should be sorted in descending order + """ + + DIRSTRING = "DESC" + + +class Group(object): + """ + This object automatically created in the `AggregateRequest.group_by()` + """ + + def __init__(self, fields, reducers): + if not reducers: + raise ValueError("Need at least one reducer") + + fields = [fields] if isinstance(fields, string_types) else fields + reducers = [reducers] if isinstance(reducers, Reducer) else reducers + + self.fields = fields + self.reducers = reducers + self.limit = Limit() + + def build_args(self): + ret = ["GROUPBY", str(len(self.fields))] + ret.extend(self.fields) + for reducer in self.reducers: + ret += ["REDUCE", reducer.NAME, str(len(reducer.args))] + ret.extend(reducer.args) + if reducer._alias is not None: + ret += ["AS", reducer._alias] + return ret + + +class Projection(object): + """ + This object automatically created in the `AggregateRequest.apply()` + """ + + def __init__(self, projector, alias=None): + self.alias = alias + self.projector = projector + + def build_args(self): + ret = ["APPLY", self.projector] + if self.alias is not None: + ret += ["AS", self.alias] + + return ret + + +class SortBy(object): + """ + This object automatically created in the `AggregateRequest.sort_by()` + """ + + def __init__(self, fields, max=0): + self.fields = fields + self.max = max + + def build_args(self): + fields_args = [] + for f in self.fields: + if isinstance(f, SortDirection): + fields_args += [f.field, f.DIRSTRING] + else: + fields_args += [f] + + ret = ["SORTBY", str(len(fields_args))] + ret.extend(fields_args) + if self.max > 0: + ret += ["MAX", str(self.max)] + + return ret + + +class AggregateRequest(object): + """ + Aggregation request which can be passed to `Client.aggregate`. + """ + + def __init__(self, query="*"): + """ + Create an aggregation request. This request may then be passed to + `client.aggregate()`. + + In order for the request to be usable, it must contain at least one + group. + + - **query** Query string for filtering records. + + All member methods (except `build_args()`) + return the object itself, making them useful for chaining. + """ + self._query = query + self._aggregateplan = [] + self._loadfields = [] + self._limit = Limit() + self._max = 0 + self._with_schema = False + self._verbatim = False + self._cursor = [] + + def load(self, *fields): + """ + Indicate the fields to be returned in the response. These fields are + returned in addition to any others implicitly specified. + + ### Parameters + + - **fields**: One or more fields in the format of `@field` + """ + self._loadfields.extend(fields) + return self + + def group_by(self, fields, *reducers): + """ + Specify by which fields to group the aggregation. + + ### Parameters + + - **fields**: Fields to group by. This can either be a single string, + or a list of strings. both cases, the field should be specified as + `@field`. + - **reducers**: One or more reducers. Reducers may be found in the + `aggregation` module. + """ + group = Group(fields, reducers) + self._aggregateplan.extend(group.build_args()) + + return self + + def apply(self, **kwexpr): + """ + Specify one or more projection expressions to add to each result + + ### Parameters + + - **kwexpr**: One or more key-value pairs for a projection. The key is + the alias for the projection, and the value is the projection + expression itself, for example `apply(square_root="sqrt(@foo)")` + """ + for alias, expr in kwexpr.items(): + projection = Projection(expr, alias) + self._aggregateplan.extend(projection.build_args()) + + return self + + def limit(self, offset, num): + """ + Sets the limit for the most recent group or query. + + If no group has been defined yet (via `group_by()`) then this sets + the limit for the initial pool of results from the query. Otherwise, + this limits the number of items operated on from the previous group. + + Setting a limit on the initial search results may be useful when + attempting to execute an aggregation on a sample of a large data set. + + ### Parameters + + - **offset**: Result offset from which to begin paging + - **num**: Number of results to return + + + Example of sorting the initial results: + + ``` + AggregateRequest("@sale_amount:[10000, inf]")\ + .limit(0, 10)\ + .group_by("@state", r.count()) + ``` + + Will only group by the states found in the first 10 results of the + query `@sale_amount:[10000, inf]`. On the other hand, + + ``` + AggregateRequest("@sale_amount:[10000, inf]")\ + .limit(0, 1000)\ + .group_by("@state", r.count()\ + .limit(0, 10) + ``` + + Will group all the results matching the query, but only return the + first 10 groups. + + If you only wish to return a *top-N* style query, consider using + `sort_by()` instead. + + """ + limit = Limit(offset, num) + self._limit = limit + return self + + def sort_by(self, *fields, **kwargs): + """ + Indicate how the results should be sorted. This can also be used for + *top-N* style queries + + ### Parameters + + - **fields**: The fields by which to sort. This can be either a single + field or a list of fields. If you wish to specify order, you can + use the `Asc` or `Desc` wrapper classes. + - **max**: Maximum number of results to return. This can be + used instead of `LIMIT` and is also faster. + + + Example of sorting by `foo` ascending and `bar` descending: + + ``` + sort_by(Asc("@foo"), Desc("@bar")) + ``` + + Return the top 10 customers: + + ``` + AggregateRequest()\ + .group_by("@customer", r.sum("@paid").alias(FIELDNAME))\ + .sort_by(Desc("@paid"), max=10) + ``` + """ + if isinstance(fields, (string_types, SortDirection)): + fields = [fields] + + max = kwargs.get("max", 0) + sortby = SortBy(fields, max) + + self._aggregateplan.extend(sortby.build_args()) + return self + + def filter(self, expressions): + """ + Specify filter for post-query results using predicates relating to + values in the result set. + + ### Parameters + + - **fields**: Fields to group by. This can either be a single string, + or a list of strings. + """ + if isinstance(expressions, string_types): + expressions = [expressions] + + for expression in expressions: + self._aggregateplan.extend(["FILTER", expression]) + + return self + + def with_schema(self): + """ + If set, the `schema` property will contain a list of `[field, type]` + entries in the result object. + """ + self._with_schema = True + return self + + def verbatim(self): + self._verbatim = True + return self + + def cursor(self, count=0, max_idle=0.0): + args = ["WITHCURSOR"] + if count: + args += ["COUNT", str(count)] + if max_idle: + args += ["MAXIDLE", str(max_idle * 1000)] + self._cursor = args + return self + + def _limit_2_args(self, limit): + if limit[1]: + return ["LIMIT"] + [str(x) for x in limit] + else: + return [] + + def build_args(self): + # @foo:bar ... + ret = [self._query] + + if self._with_schema: + ret.append("WITHSCHEMA") + + if self._verbatim: + ret.append("VERBATIM") + + if self._cursor: + ret += self._cursor + + if self._loadfields: + ret.append("LOAD") + ret.append(str(len(self._loadfields))) + ret.extend(self._loadfields) + + ret.extend(self._aggregateplan) + + ret += self._limit.build_args() + + return ret + + +class Cursor(object): + def __init__(self, cid): + self.cid = cid + self.max_idle = 0 + self.count = 0 + + def build_args(self): + args = [str(self.cid)] + if self.max_idle: + args += ["MAXIDLE", str(self.max_idle)] + if self.count: + args += ["COUNT", str(self.count)] + return args + + +class AggregateResult(object): + def __init__(self, rows, cursor, schema): + self.rows = rows + self.cursor = cursor + self.schema = schema + + def __repr__(self): + return "<{} at 0x{:x} Rows={}, Cursor={}>".format( + self.__class__.__name__, + id(self), + len(self.rows), + self.cursor.cid if self.cursor else -1, + ) diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py new file mode 100644 index 0000000000..6074d2959b --- /dev/null +++ b/redis/commands/search/commands.py @@ -0,0 +1,704 @@ +import itertools +import time +import six + +from .document import Document +from .result import Result +from .query import Query +from ._util import to_string +from .aggregation import AggregateRequest, AggregateResult, Cursor +from .suggestion import SuggestionParser + +NUMERIC = "NUMERIC" + +CREATE_CMD = "FT.CREATE" +ALTER_CMD = "FT.ALTER" +SEARCH_CMD = "FT.SEARCH" +ADD_CMD = "FT.ADD" +ADDHASH_CMD = "FT.ADDHASH" +DROP_CMD = "FT.DROP" +EXPLAIN_CMD = "FT.EXPLAIN" +DEL_CMD = "FT.DEL" +AGGREGATE_CMD = "FT.AGGREGATE" +CURSOR_CMD = "FT.CURSOR" +SPELLCHECK_CMD = "FT.SPELLCHECK" +DICT_ADD_CMD = "FT.DICTADD" +DICT_DEL_CMD = "FT.DICTDEL" +DICT_DUMP_CMD = "FT.DICTDUMP" +GET_CMD = "FT.GET" +MGET_CMD = "FT.MGET" +CONFIG_CMD = "FT.CONFIG" +TAGVALS_CMD = "FT.TAGVALS" +ALIAS_ADD_CMD = "FT.ALIASADD" +ALIAS_UPDATE_CMD = "FT.ALIASUPDATE" +ALIAS_DEL_CMD = "FT.ALIASDEL" +INFO_CMD = "FT.INFO" +SUGADD_COMMAND = "FT.SUGADD" +SUGDEL_COMMAND = "FT.SUGDEL" +SUGLEN_COMMAND = "FT.SUGLEN" +SUGGET_COMMAND = "FT.SUGGET" +SYNUPDATE_CMD = "FT.SYNUPDATE" +SYNDUMP_CMD = "FT.SYNDUMP" + +NOOFFSETS = "NOOFFSETS" +NOFIELDS = "NOFIELDS" +STOPWORDS = "STOPWORDS" +WITHSCORES = "WITHSCORES" +FUZZY = "FUZZY" +WITHPAYLOADS = "WITHPAYLOADS" + + +class SearchCommands: + """Search commands.""" + + def batch_indexer(self, chunk_size=100): + """ + Create a new batch indexer from the client with a given chunk size + """ + return self.BatchIndexer(self, chunk_size=chunk_size) + + def create_index( + self, + fields, + no_term_offsets=False, + no_field_flags=False, + stopwords=None, + definition=None, + ): + """ + Create the search index. The index must not already exist. + + ### Parameters: + + - **fields**: a list of TextField or NumericField objects + - **no_term_offsets**: If true, we will not save term offsets in + the index + - **no_field_flags**: If true, we will not save field flags that + allow searching in specific fields + - **stopwords**: If not None, we create the index with this custom + stopword list. The list can be empty + """ + + args = [CREATE_CMD, self.index_name] + if definition is not None: + args += definition.args + if no_term_offsets: + args.append(NOOFFSETS) + if no_field_flags: + args.append(NOFIELDS) + if stopwords is not None and isinstance(stopwords, (list, tuple, set)): + args += [STOPWORDS, len(stopwords)] + if len(stopwords) > 0: + args += list(stopwords) + + args.append("SCHEMA") + try: + args += list(itertools.chain(*(f.redis_args() for f in fields))) + except TypeError: + args += fields.redis_args() + + return self.execute_command(*args) + + def alter_schema_add(self, fields): + """ + Alter the existing search index by adding new fields. The index + must already exist. + + ### Parameters: + + - **fields**: a list of Field objects to add for the index + """ + + args = [ALTER_CMD, self.index_name, "SCHEMA", "ADD"] + try: + args += list(itertools.chain(*(f.redis_args() for f in fields))) + except TypeError: + args += fields.redis_args() + + return self.execute_command(*args) + + def drop_index(self, delete_documents=True): + """ + Drop the index if it exists. Deprecated from RediSearch 2.0. + + ### Parameters: + + - **delete_documents**: If `True`, all documents will be deleted. + """ + keep_str = "" if delete_documents else "KEEPDOCS" + return self.execute_command(DROP_CMD, self.index_name, keep_str) + + def dropindex(self, delete_documents=False): + """ + Drop the index if it exists. + Replaced `drop_index` in RediSearch 2.0. + Default behavior was changed to not delete the indexed documents. + + ### Parameters: + + - **delete_documents**: If `True`, all documents will be deleted. + """ + keep_str = "" if delete_documents else "KEEPDOCS" + return self.execute_command(DROP_CMD, self.index_name, keep_str) + + def _add_document( + self, + doc_id, + conn=None, + nosave=False, + score=1.0, + payload=None, + replace=False, + partial=False, + language=None, + no_create=False, + **fields + ): + """ + Internal add_document used for both batch and single doc indexing + """ + if conn is None: + conn = self.client + + if partial or no_create: + replace = True + + args = [ADD_CMD, self.index_name, doc_id, score] + if nosave: + args.append("NOSAVE") + if payload is not None: + args.append("PAYLOAD") + args.append(payload) + if replace: + args.append("REPLACE") + if partial: + args.append("PARTIAL") + if no_create: + args.append("NOCREATE") + if language: + args += ["LANGUAGE", language] + args.append("FIELDS") + args += list(itertools.chain(*fields.items())) + return conn.execute_command(*args) + + def _add_document_hash( + self, + doc_id, + conn=None, + score=1.0, + language=None, + replace=False, + ): + """ + Internal add_document_hash used for both batch and single doc indexing + """ + if conn is None: + conn = self.client + + args = [ADDHASH_CMD, self.index_name, doc_id, score] + + if replace: + args.append("REPLACE") + + if language: + args += ["LANGUAGE", language] + + return conn.execute_command(*args) + + def add_document( + self, + doc_id, + nosave=False, + score=1.0, + payload=None, + replace=False, + partial=False, + language=None, + no_create=False, + **fields + ): + """ + Add a single document to the index. + + ### Parameters + + - **doc_id**: the id of the saved document. + - **nosave**: if set to true, we just index the document, and don't + save a copy of it. This means that searches will just + return ids. + - **score**: the document ranking, between 0.0 and 1.0 + - **payload**: optional inner-index payload we can save for fast + i access in scoring functions + - **replace**: if True, and the document already is in the index, + we perform an update and reindex the document + - **partial**: if True, the fields specified will be added to the + existing document. + This has the added benefit that any fields specified + with `no_index` + will not be reindexed again. Implies `replace` + - **language**: Specify the language used for document tokenization. + - **no_create**: if True, the document is only updated and reindexed + if it already exists. + If the document does not exist, an error will be + returned. Implies `replace` + - **fields** kwargs dictionary of the document fields to be saved + and/or indexed. + NOTE: Geo points shoule be encoded as strings of "lon,lat" + """ + return self._add_document( + doc_id, + conn=None, + nosave=nosave, + score=score, + payload=payload, + replace=replace, + partial=partial, + language=language, + no_create=no_create, + **fields + ) + + def add_document_hash( + self, + doc_id, + score=1.0, + language=None, + replace=False, + ): + """ + Add a hash document to the index. + + ### Parameters + + - **doc_id**: the document's id. This has to be an existing HASH key + in Redis that will hold the fields the index needs. + - **score**: the document ranking, between 0.0 and 1.0 + - **replace**: if True, and the document already is in the index, we + perform an update and reindex the document + - **language**: Specify the language used for document tokenization. + """ + return self._add_document_hash( + doc_id, + conn=None, + score=score, + language=language, + replace=replace, + ) + + def delete_document(self, doc_id, conn=None, delete_actual_document=False): + """ + Delete a document from index + Returns 1 if the document was deleted, 0 if not + + ### Parameters + + - **delete_actual_document**: if set to True, RediSearch also delete + the actual document if it is in the index + """ + args = [DEL_CMD, self.index_name, doc_id] + if conn is None: + conn = self.client + if delete_actual_document: + args.append("DD") + + return conn.execute_command(*args) + + def load_document(self, id): + """ + Load a single document by id + """ + fields = self.client.hgetall(id) + if six.PY3: + f2 = {to_string(k): to_string(v) for k, v in fields.items()} + fields = f2 + + try: + del fields["id"] + except KeyError: + pass + + return Document(id=id, **fields) + + def get(self, *ids): + """ + Returns the full contents of multiple documents. + + ### Parameters + + - **ids**: the ids of the saved documents. + """ + + return self.client.execute_command(MGET_CMD, self.index_name, *ids) + + def info(self): + """ + Get info an stats about the the current index, including the number of + documents, memory consumption, etc + """ + + res = self.client.execute_command(INFO_CMD, self.index_name) + it = six.moves.map(to_string, res) + return dict(six.moves.zip(it, it)) + + def _mk_query_args(self, query): + args = [self.index_name] + + if isinstance(query, six.string_types): + # convert the query from a text to a query object + query = Query(query) + if not isinstance(query, Query): + raise ValueError("Bad query type %s" % type(query)) + + args += query.get_args() + return args, query + + def search(self, query): + """ + Search the index for a given query, and return a result of documents + + ### Parameters + + - **query**: the search query. Either a text for simple queries with + default parameters, or a Query object for complex queries. + See RediSearch's documentation on query format + """ + args, query = self._mk_query_args(query) + st = time.time() + res = self.execute_command(SEARCH_CMD, *args) + + return Result( + res, + not query._no_content, + duration=(time.time() - st) * 1000.0, + has_payload=query._with_payloads, + with_scores=query._with_scores, + ) + + def explain(self, query): + args, query_text = self._mk_query_args(query) + return self.execute_command(EXPLAIN_CMD, *args) + + def aggregate(self, query): + """ + Issue an aggregation query + + ### Parameters + + **query**: This can be either an `AggeregateRequest`, or a `Cursor` + + An `AggregateResult` object is returned. You can access the rows from + its `rows` property, which will always yield the rows of the result. + """ + if isinstance(query, AggregateRequest): + has_cursor = bool(query._cursor) + cmd = [AGGREGATE_CMD, self.index_name] + query.build_args() + elif isinstance(query, Cursor): + has_cursor = True + cmd = [CURSOR_CMD, "READ", self.index_name] + query.build_args() + else: + raise ValueError("Bad query", query) + + raw = self.execute_command(*cmd) + if has_cursor: + if isinstance(query, Cursor): + query.cid = raw[1] + cursor = query + else: + cursor = Cursor(raw[1]) + raw = raw[0] + else: + cursor = None + + if isinstance(query, AggregateRequest) and query._with_schema: + schema = raw[0] + rows = raw[2:] + else: + schema = None + rows = raw[1:] + + res = AggregateResult(rows, cursor, schema) + return res + + def spellcheck(self, query, distance=None, include=None, exclude=None): + """ + Issue a spellcheck query + + ### Parameters + + **query**: search query. + **distance***: the maximal Levenshtein distance for spelling + suggestions (default: 1, max: 4). + **include**: specifies an inclusion custom dictionary. + **exclude**: specifies an exclusion custom dictionary. + """ + cmd = [SPELLCHECK_CMD, self.index_name, query] + if distance: + cmd.extend(["DISTANCE", distance]) + + if include: + cmd.extend(["TERMS", "INCLUDE", include]) + + if exclude: + cmd.extend(["TERMS", "EXCLUDE", exclude]) + + raw = self.execute_command(*cmd) + + corrections = {} + if raw == 0: + return corrections + + for _correction in raw: + if isinstance(_correction, six.integer_types) and _correction == 0: + continue + + if len(_correction) != 3: + continue + if not _correction[2]: + continue + if not _correction[2][0]: + continue + + # For spellcheck output + # 1) 1) "TERM" + # 2) "{term1}" + # 3) 1) 1) "{score1}" + # 2) "{suggestion1}" + # 2) 1) "{score2}" + # 2) "{suggestion2}" + # + # Following dictionary will be made + # corrections = { + # '{term1}': [ + # {'score': '{score1}', 'suggestion': '{suggestion1}'}, + # {'score': '{score2}', 'suggestion': '{suggestion2}'} + # ] + # } + corrections[_correction[1]] = [ + {"score": _item[0], "suggestion": _item[1]} + for _item in _correction[2] + ] + + return corrections + + def dict_add(self, name, *terms): + """Adds terms to a dictionary. + + ### Parameters + + - **name**: Dictionary name. + - **terms**: List of items for adding to the dictionary. + """ + cmd = [DICT_ADD_CMD, name] + cmd.extend(terms) + return self.execute_command(*cmd) + + def dict_del(self, name, *terms): + """Deletes terms from a dictionary. + + ### Parameters + + - **name**: Dictionary name. + - **terms**: List of items for removing from the dictionary. + """ + cmd = [DICT_DEL_CMD, name] + cmd.extend(terms) + return self.execute_command(*cmd) + + def dict_dump(self, name): + """Dumps all terms in the given dictionary. + + ### Parameters + + - **name**: Dictionary name. + """ + cmd = [DICT_DUMP_CMD, name] + return self.execute_command(*cmd) + + def config_set(self, option, value): + """Set runtime configuration option. + + ### Parameters + + - **option**: the name of the configuration option. + - **value**: a value for the configuration option. + """ + cmd = [CONFIG_CMD, "SET", option, value] + raw = self.execute_command(*cmd) + return raw == "OK" + + def config_get(self, option): + """Get runtime configuration option value. + + ### Parameters + + - **option**: the name of the configuration option. + """ + cmd = [CONFIG_CMD, "GET", option] + res = {} + raw = self.execute_command(*cmd) + if raw: + for kvs in raw: + res[kvs[0]] = kvs[1] + return res + + def tagvals(self, tagfield): + """ + Return a list of all possible tag values + + ### Parameters + + - **tagfield**: Tag field name + """ + + return self.execute_command(TAGVALS_CMD, self.index_name, tagfield) + + def aliasadd(self, alias): + """ + Alias a search index - will fail if alias already exists + + ### Parameters + + - **alias**: Name of the alias to create + """ + + return self.execute_command(ALIAS_ADD_CMD, alias, self.index_name) + + def aliasupdate(self, alias): + """ + Updates an alias - will fail if alias does not already exist + + ### Parameters + + - **alias**: Name of the alias to create + """ + + return self.execute_command(ALIAS_UPDATE_CMD, alias, self.index_name) + + def aliasdel(self, alias): + """ + Removes an alias to a search index + + ### Parameters + + - **alias**: Name of the alias to delete + """ + return self.execute_command(ALIAS_DEL_CMD, alias) + + def sugadd(self, key, *suggestions, **kwargs): + """ + Add suggestion terms to the AutoCompleter engine. Each suggestion has + a score and string. + If kwargs["increment"] is true and the terms are already in the + server's dictionary, we increment their scores. + More information `here `_. # noqa + """ + # If Transaction is not False it will MULTI/EXEC which will error + pipe = self.pipeline(transaction=False) + for sug in suggestions: + args = [SUGADD_COMMAND, key, sug.string, sug.score] + if kwargs.get("increment"): + args.append("INCR") + if sug.payload: + args.append("PAYLOAD") + args.append(sug.payload) + + pipe.execute_command(*args) + + return pipe.execute()[-1] + + def suglen(self, key): + """ + Return the number of entries in the AutoCompleter index. + More information `here `_. # noqa + """ + return self.execute_command(SUGLEN_COMMAND, key) + + def sugdel(self, key, string): + """ + Delete a string from the AutoCompleter index. + Returns 1 if the string was found and deleted, 0 otherwise. + More information `here `_. # noqa + """ + return self.execute_command(SUGDEL_COMMAND, key, string) + + def sugget( + self, key, prefix, fuzzy=False, num=10, with_scores=False, + with_payloads=False + ): + """ + Get a list of suggestions from the AutoCompleter, for a given prefix. + More information `here `_. # noqa + + Parameters: + + prefix : str + The prefix we are searching. **Must be valid ascii or utf-8** + fuzzy : bool + If set to true, the prefix search is done in fuzzy mode. + **NOTE**: Running fuzzy searches on short (<3 letters) prefixes + can be very + slow, and even scan the entire index. + with_scores : bool + If set to true, we also return the (refactored) score of + each suggestion. + This is normally not needed, and is NOT the original score + inserted into the index. + with_payloads : bool + Return suggestion payloads + num : int + The maximum number of results we return. Note that we might + return less. The algorithm trims irrelevant suggestions. + + Returns: + + list: + A list of Suggestion objects. If with_scores was False, the + score of all suggestions is 1. + """ + args = [SUGGET_COMMAND, key, prefix, "MAX", num] + if fuzzy: + args.append(FUZZY) + if with_scores: + args.append(WITHSCORES) + if with_payloads: + args.append(WITHPAYLOADS) + + ret = self.execute_command(*args) + results = [] + if not ret: + return results + + parser = SuggestionParser(with_scores, with_payloads, ret) + return [s for s in parser] + + def synupdate(self, groupid, skipinitial=False, *terms): + """ + Updates a synonym group. + The command is used to create or update a synonym group with + additional terms. + Only documents which were indexed after the update will be affected. + + Parameters: + + groupid : + Synonym group id. + skipinitial : bool + If set to true, we do not scan and index. + terms : + The terms. + """ + cmd = [SYNUPDATE_CMD, self.index_name, groupid] + if skipinitial: + cmd.extend(["SKIPINITIALSCAN"]) + cmd.extend(terms) + return self.execute_command(*cmd) + + def syndump(self): + """ + Dumps the contents of a synonym group. + + The command is used to dump the synonyms data structure. + Returns a list of synonym terms and their synonym group ids. + """ + raw = self.execute_command(SYNDUMP_CMD, self.index_name) + return {raw[i]: raw[i + 1] for i in range(0, len(raw), 2)} diff --git a/redis/commands/search/document.py b/redis/commands/search/document.py new file mode 100644 index 0000000000..26ede34ef1 --- /dev/null +++ b/redis/commands/search/document.py @@ -0,0 +1,16 @@ +import six + + +class Document(object): + """ + Represents a single document in a result set + """ + + def __init__(self, id, payload=None, **fields): + self.id = id + self.payload = payload + for k, v in six.iteritems(fields): + setattr(self, k, v) + + def __repr__(self): + return "Document %s" % self.__dict__ diff --git a/redis/commands/search/field.py b/redis/commands/search/field.py new file mode 100644 index 0000000000..45114a42b7 --- /dev/null +++ b/redis/commands/search/field.py @@ -0,0 +1,94 @@ +class Field(object): + + NUMERIC = "NUMERIC" + TEXT = "TEXT" + WEIGHT = "WEIGHT" + GEO = "GEO" + TAG = "TAG" + SORTABLE = "SORTABLE" + NOINDEX = "NOINDEX" + AS = "AS" + + def __init__(self, name, args=[], sortable=False, + no_index=False, as_name=None): + self.name = name + self.args = args + self.args_suffix = list() + self.as_name = as_name + + if sortable: + self.args_suffix.append(Field.SORTABLE) + if no_index: + self.args_suffix.append(Field.NOINDEX) + + if no_index and not sortable: + raise ValueError("Non-Sortable non-Indexable fields are ignored") + + def append_arg(self, value): + self.args.append(value) + + def redis_args(self): + args = [self.name] + if self.as_name: + args += [self.AS, self.as_name] + args += self.args + args += self.args_suffix + return args + + +class TextField(Field): + """ + TextField is used to define a text field in a schema definition + """ + + NOSTEM = "NOSTEM" + PHONETIC = "PHONETIC" + + def __init__( + self, name, weight=1.0, no_stem=False, phonetic_matcher=None, **kwargs + ): + Field.__init__(self, name, + args=[Field.TEXT, Field.WEIGHT, weight], **kwargs) + + if no_stem: + Field.append_arg(self, self.NOSTEM) + if phonetic_matcher and phonetic_matcher in [ + "dm:en", + "dm:fr", + "dm:pt", + "dm:es", + ]: + Field.append_arg(self, self.PHONETIC) + Field.append_arg(self, phonetic_matcher) + + +class NumericField(Field): + """ + NumericField is used to define a numeric field in a schema definition + """ + + def __init__(self, name, **kwargs): + Field.__init__(self, name, args=[Field.NUMERIC], **kwargs) + + +class GeoField(Field): + """ + GeoField is used to define a geo-indexing field in a schema definition + """ + + def __init__(self, name, **kwargs): + Field.__init__(self, name, args=[Field.GEO], **kwargs) + + +class TagField(Field): + """ + TagField is a tag-indexing field with simpler compression and tokenization. + See http://redisearch.io/Tags/ + """ + + SEPARATOR = "SEPARATOR" + + def __init__(self, name, separator=",", **kwargs): + Field.__init__( + self, name, args=[Field.TAG, self.SEPARATOR, separator], **kwargs + ) diff --git a/redis/commands/search/indexDefinition.py b/redis/commands/search/indexDefinition.py new file mode 100644 index 0000000000..4fbc6095c5 --- /dev/null +++ b/redis/commands/search/indexDefinition.py @@ -0,0 +1,80 @@ +from enum import Enum + + +class IndexType(Enum): + """Enum of the currently supported index types.""" + + HASH = 1 + JSON = 2 + + +class IndexDefinition(object): + """IndexDefinition is used to define a index definition for automatic + indexing on Hash or Json update.""" + + def __init__( + self, + prefix=[], + filter=None, + language_field=None, + language=None, + score_field=None, + score=1.0, + payload_field=None, + index_type=None, + ): + self.args = [] + self._appendIndexType(index_type) + self._appendPrefix(prefix) + self._appendFilter(filter) + self._appendLanguage(language_field, language) + self._appendScore(score_field, score) + self._appendPayload(payload_field) + + def _appendIndexType(self, index_type): + """Append `ON HASH` or `ON JSON` according to the enum.""" + if index_type is IndexType.HASH: + self.args.extend(["ON", "HASH"]) + elif index_type is IndexType.JSON: + self.args.extend(["ON", "JSON"]) + elif index_type is not None: + raise RuntimeError("index_type must be one of {}". + format(list(IndexType))) + + def _appendPrefix(self, prefix): + """Append PREFIX.""" + if len(prefix) > 0: + self.args.append("PREFIX") + self.args.append(len(prefix)) + for p in prefix: + self.args.append(p) + + def _appendFilter(self, filter): + """Append FILTER.""" + if filter is not None: + self.args.append("FILTER") + self.args.append(filter) + + def _appendLanguage(self, language_field, language): + """Append LANGUAGE_FIELD and LANGUAGE.""" + if language_field is not None: + self.args.append("LANGUAGE_FIELD") + self.args.append(language_field) + if language is not None: + self.args.append("LANGUAGE") + self.args.append(language) + + def _appendScore(self, score_field, score): + """Append SCORE_FIELD and SCORE.""" + if score_field is not None: + self.args.append("SCORE_FIELD") + self.args.append(score_field) + if score is not None: + self.args.append("SCORE") + self.args.append(score) + + def _appendPayload(self, payload_field): + """Append PAYLOAD_FIELD.""" + if payload_field is not None: + self.args.append("PAYLOAD_FIELD") + self.args.append(payload_field) diff --git a/redis/commands/search/query.py b/redis/commands/search/query.py new file mode 100644 index 0000000000..e2db7a422b --- /dev/null +++ b/redis/commands/search/query.py @@ -0,0 +1,328 @@ +import six + + +class Query(object): + """ + Query is used to build complex queries that have more parameters than just + the query string. The query string is set in the constructor, and other + options have setter functions. + + The setter functions return the query object, so they can be chained, + i.e. `Query("foo").verbatim().filter(...)` etc. + """ + + def __init__(self, query_string): + """ + Create a new query object. + The query string is set in the constructor, and other options have + setter functions. + """ + + self._query_string = query_string + self._offset = 0 + self._num = 10 + self._no_content = False + self._no_stopwords = False + self._fields = None + self._verbatim = False + self._with_payloads = False + self._with_scores = False + self._scorer = False + self._filters = list() + self._ids = None + self._slop = -1 + self._in_order = False + self._sortby = None + self._return_fields = [] + self._summarize_fields = [] + self._highlight_fields = [] + self._language = None + self._expander = None + + def query_string(self): + """Return the query string of this query only.""" + return self._query_string + + def limit_ids(self, *ids): + """Limit the results to a specific set of pre-known document + ids of any length.""" + self._ids = ids + return self + + def return_fields(self, *fields): + """Add fields to return fields.""" + self._return_fields += fields + return self + + def return_field(self, field, as_field=None): + """Add field to return fields (Optional: add 'AS' name + to the field).""" + self._return_fields.append(field) + if as_field is not None: + self._return_fields += ("AS", as_field) + return self + + def _mk_field_list(self, fields): + if not fields: + return [] + return \ + [fields] if isinstance(fields, six.string_types) else list(fields) + + def summarize(self, fields=None, context_len=None, + num_frags=None, sep=None): + """ + Return an abridged format of the field, containing only the segments of + the field which contain the matching term(s). + + If `fields` is specified, then only the mentioned fields are + summarized; otherwise all results are summarized. + + Server side defaults are used for each option (except `fields`) + if not specified + + - **fields** List of fields to summarize. All fields are summarized + if not specified + - **context_len** Amount of context to include with each fragment + - **num_frags** Number of fragments per document + - **sep** Separator string to separate fragments + """ + args = ["SUMMARIZE"] + fields = self._mk_field_list(fields) + if fields: + args += ["FIELDS", str(len(fields))] + fields + + if context_len is not None: + args += ["LEN", str(context_len)] + if num_frags is not None: + args += ["FRAGS", str(num_frags)] + if sep is not None: + args += ["SEPARATOR", sep] + + self._summarize_fields = args + return self + + def highlight(self, fields=None, tags=None): + """ + Apply specified markup to matched term(s) within the returned field(s). + + - **fields** If specified then only those mentioned fields are + highlighted, otherwise all fields are highlighted + - **tags** A list of two strings to surround the match. + """ + args = ["HIGHLIGHT"] + fields = self._mk_field_list(fields) + if fields: + args += ["FIELDS", str(len(fields))] + fields + if tags: + args += ["TAGS"] + list(tags) + + self._highlight_fields = args + return self + + def language(self, language): + """ + Analyze the query as being in the specified language. + + :param language: The language (e.g. `chinese` or `english`) + """ + self._language = language + return self + + def slop(self, slop): + """Allow a maximum of N intervening non matched terms between + phrase terms (0 means exact phrase). + """ + self._slop = slop + return self + + def in_order(self): + """ + Match only documents where the query terms appear in + the same order in the document. + i.e. for the query "hello world", we do not match "world hello" + """ + self._in_order = True + return self + + def scorer(self, scorer): + """ + Use a different scoring function to evaluate document relevance. + Default is `TFIDF`. + + :param scorer: The scoring function to use + (e.g. `TFIDF.DOCNORM` or `BM25`) + """ + self._scorer = scorer + return self + + def get_args(self): + """Format the redis arguments for this query and return them.""" + args = [self._query_string] + args += self._get_args_tags() + args += self._summarize_fields + self._highlight_fields + args += ["LIMIT", self._offset, self._num] + return args + + def _get_args_tags(self): + args = [] + if self._no_content: + args.append("NOCONTENT") + if self._fields: + args.append("INFIELDS") + args.append(len(self._fields)) + args += self._fields + if self._verbatim: + args.append("VERBATIM") + if self._no_stopwords: + args.append("NOSTOPWORDS") + if self._filters: + for flt in self._filters: + if not isinstance(flt, Filter): + raise AttributeError("Did not receive a Filter object.") + args += flt.args + if self._with_payloads: + args.append("WITHPAYLOADS") + if self._scorer: + args += ["SCORER", self._scorer] + if self._with_scores: + args.append("WITHSCORES") + if self._ids: + args.append("INKEYS") + args.append(len(self._ids)) + args += self._ids + if self._slop >= 0: + args += ["SLOP", self._slop] + if self._in_order: + args.append("INORDER") + if self._return_fields: + args.append("RETURN") + args.append(len(self._return_fields)) + args += self._return_fields + if self._sortby: + if not isinstance(self._sortby, SortbyField): + raise AttributeError("Did not receive a SortByField.") + args.append("SORTBY") + args += self._sortby.args + if self._language: + args += ["LANGUAGE", self._language] + if self._expander: + args += ["EXPANDER", self._expander] + + return args + + def paging(self, offset, num): + """ + Set the paging for the query (defaults to 0..10). + + - **offset**: Paging offset for the results. Defaults to 0 + - **num**: How many results do we want + """ + self._offset = offset + self._num = num + return self + + def verbatim(self): + """Set the query to be verbatim, i.e. use no query expansion + or stemming. + """ + self._verbatim = True + return self + + def no_content(self): + """Set the query to only return ids and not the document content.""" + self._no_content = True + return self + + def no_stopwords(self): + """ + Prevent the query from being filtered for stopwords. + Only useful in very big queries that you are certain contain + no stopwords. + """ + self._no_stopwords = True + return self + + def with_payloads(self): + """Ask the engine to return document payloads.""" + self._with_payloads = True + return self + + def with_scores(self): + """Ask the engine to return document search scores.""" + self._with_scores = True + return self + + def limit_fields(self, *fields): + """ + Limit the search to specific TEXT fields only. + + - **fields**: A list of strings, case sensitive field names + from the defined schema. + """ + self._fields = fields + return self + + def add_filter(self, flt): + """ + Add a numeric or geo filter to the query. + **Currently only one of each filter is supported by the engine** + + - **flt**: A NumericFilter or GeoFilter object, used on a + corresponding field + """ + + self._filters.append(flt) + return self + + def sort_by(self, field, asc=True): + """ + Add a sortby field to the query. + + - **field** - the name of the field to sort by + - **asc** - when `True`, sorting will be done in asceding order + """ + self._sortby = SortbyField(field, asc) + return self + + def expander(self, expander): + """ + Add a expander field to the query. + + - **expander** - the name of the expander + """ + self._expander = expander + return self + + +class Filter(object): + def __init__(self, keyword, field, *args): + self.args = [keyword, field] + list(args) + + +class NumericFilter(Filter): + INF = "+inf" + NEG_INF = "-inf" + + def __init__(self, field, minval, maxval, minExclusive=False, + maxExclusive=False): + args = [ + minval if not minExclusive else "({}".format(minval), + maxval if not maxExclusive else "({}".format(maxval), + ] + + Filter.__init__(self, "FILTER", field, *args) + + +class GeoFilter(Filter): + METERS = "m" + KILOMETERS = "km" + FEET = "ft" + MILES = "mi" + + def __init__(self, field, lon, lat, radius, unit=KILOMETERS): + Filter.__init__(self, "GEOFILTER", field, lon, lat, radius, unit) + + +class SortbyField(object): + def __init__(self, field, asc=True): + self.args = [field, "ASC" if asc else "DESC"] diff --git a/redis/commands/search/querystring.py b/redis/commands/search/querystring.py new file mode 100644 index 0000000000..f5f59b7e53 --- /dev/null +++ b/redis/commands/search/querystring.py @@ -0,0 +1,324 @@ +from six import string_types, integer_types + + +def tags(*t): + """ + Indicate that the values should be matched to a tag field + + ### Parameters + + - **t**: Tags to search for + """ + if not t: + raise ValueError("At least one tag must be specified") + return TagValue(*t) + + +def between(a, b, inclusive_min=True, inclusive_max=True): + """ + Indicate that value is a numeric range + """ + return RangeValue(a, b, inclusive_min=inclusive_min, + inclusive_max=inclusive_max) + + +def equal(n): + """ + Match a numeric value + """ + return between(n, n) + + +def lt(n): + """ + Match any value less than n + """ + return between(None, n, inclusive_max=False) + + +def le(n): + """ + Match any value less or equal to n + """ + return between(None, n, inclusive_max=True) + + +def gt(n): + """ + Match any value greater than n + """ + return between(n, None, inclusive_min=False) + + +def ge(n): + """ + Match any value greater or equal to n + """ + return between(n, None, inclusive_min=True) + + +def geo(lat, lon, radius, unit="km"): + """ + Indicate that value is a geo region + """ + return GeoValue(lat, lon, radius, unit) + + +class Value(object): + @property + def combinable(self): + """ + Whether this type of value may be combined with other values + for the same field. This makes the filter potentially more efficient + """ + return False + + @staticmethod + def make_value(v): + """ + Convert an object to a value, if it is not a value already + """ + if isinstance(v, Value): + return v + return ScalarValue(v) + + def to_string(self): + raise NotImplementedError() + + def __str__(self): + return self.to_string() + + +class RangeValue(Value): + combinable = False + + def __init__(self, a, b, inclusive_min=False, inclusive_max=False): + if a is None: + a = "-inf" + if b is None: + b = "inf" + self.range = [str(a), str(b)] + self.inclusive_min = inclusive_min + self.inclusive_max = inclusive_max + + def to_string(self): + return "[{1}{0[0]} {2}{0[1]}]".format( + self.range, + "(" if not self.inclusive_min else "", + "(" if not self.inclusive_max else "", + ) + + +class ScalarValue(Value): + combinable = True + + def __init__(self, v): + self.v = str(v) + + def to_string(self): + return self.v + + +class TagValue(Value): + combinable = False + + def __init__(self, *tags): + self.tags = tags + + def to_string(self): + return "{" + " | ".join(str(t) for t in self.tags) + "}" + + +class GeoValue(Value): + def __init__(self, lon, lat, radius, unit="km"): + self.lon = lon + self.lat = lat + self.radius = radius + self.unit = unit + + +class Node(object): + def __init__(self, *children, **kwparams): + """ + Create a node + + ### Parameters + + - **children**: One or more sub-conditions. These can be additional + `intersect`, `disjunct`, `union`, `optional`, or any other `Node` + type. + + The semantics of multiple conditions are dependent on the type of + query. For an `intersection` node, this amounts to a logical AND, + for a `union` node, this amounts to a logical `OR`. + + - **kwparams**: key-value parameters. Each key is the name of a field, + and the value should be a field value. This can be one of the + following: + + - Simple string (for text field matches) + - value returned by one of the helper functions + - list of either a string or a value + + + ### Examples + + Field `num` should be between 1 and 10 + ``` + intersect(num=between(1, 10) + ``` + + Name can either be `bob` or `john` + + ``` + union(name=("bob", "john")) + ``` + + Don't select countries in Israel, Japan, or US + + ``` + disjunct_union(country=("il", "jp", "us")) + ``` + """ + + self.params = [] + + kvparams = {} + for k, v in kwparams.items(): + curvals = kvparams.setdefault(k, []) + if isinstance(v, (string_types, integer_types, float)): + curvals.append(Value.make_value(v)) + elif isinstance(v, Value): + curvals.append(v) + else: + curvals.extend(Value.make_value(subv) for subv in v) + + self.params += [Node.to_node(p) for p in children] + + for k, v in kvparams.items(): + self.params.extend(self.join_fields(k, v)) + + def join_fields(self, key, vals): + if len(vals) == 1: + return [BaseNode("@{}:{}".format(key, vals[0].to_string()))] + if not vals[0].combinable: + return [BaseNode("@{}:{}".format(key, + v.to_string())) for v in vals] + s = BaseNode( + "@{}:({})".format(key, + self.JOINSTR.join(v.to_string() for v in vals)) + ) + return [s] + + @classmethod + def to_node(cls, obj): # noqa + if isinstance(obj, Node): + return obj + return BaseNode(obj) + + @property + def JOINSTR(self): + raise NotImplementedError() + + def to_string(self, with_parens=None): + with_parens = self._should_use_paren(with_parens) + pre, post = ("(", ")") if with_parens else ("", "") + return "{}{}{}".format( + pre, self.JOINSTR.join(n.to_string() for n in self.params), post + ) + + def _should_use_paren(self, optval): + if optval is not None: + return optval + return len(self.params) > 1 + + def __str__(self): + return self.to_string() + + +class BaseNode(Node): + def __init__(self, s): + super(BaseNode, self).__init__() + self.s = str(s) + + def to_string(self, with_parens=None): + return self.s + + +class IntersectNode(Node): + """ + Create an intersection node. All children need to be satisfied in order for + this node to evaluate as true + """ + + JOINSTR = " " + + +class UnionNode(Node): + """ + Create a union node. Any of the children need to be satisfied in order for + this node to evaluate as true + """ + + JOINSTR = "|" + + +class DisjunctNode(IntersectNode): + """ + Create a disjunct node. In order for this node to be true, all of its + children must evaluate to false + """ + + def to_string(self, with_parens=None): + with_parens = self._should_use_paren(with_parens) + ret = super(DisjunctNode, self).to_string(with_parens=False) + if with_parens: + return "(-" + ret + ")" + else: + return "-" + ret + + +class DistjunctUnion(DisjunctNode): + """ + This node is true if *all* of its children are false. This is equivalent to + ``` + disjunct(union(...)) + ``` + """ + + JOINSTR = "|" + + +class OptionalNode(IntersectNode): + """ + Create an optional node. If this nodes evaluates to true, then the document + will be rated higher in score/rank. + """ + + def to_string(self, with_parens=None): + with_parens = self._should_use_paren(with_parens) + ret = super(OptionalNode, self).to_string(with_parens=False) + if with_parens: + return "(~" + ret + ")" + else: + return "~" + ret + + +def intersect(*args, **kwargs): + return IntersectNode(*args, **kwargs) + + +def union(*args, **kwargs): + return UnionNode(*args, **kwargs) + + +def disjunct(*args, **kwargs): + return DisjunctNode(*args, **kwargs) + + +def disjunct_union(*args, **kwargs): + return DistjunctUnion(*args, **kwargs) + + +def querystring(*args, **kwargs): + return intersect(*args, **kwargs).to_string() diff --git a/redis/commands/search/reducers.py b/redis/commands/search/reducers.py new file mode 100644 index 0000000000..6cbbf2f355 --- /dev/null +++ b/redis/commands/search/reducers.py @@ -0,0 +1,178 @@ +from .aggregation import Reducer, SortDirection + + +class FieldOnlyReducer(Reducer): + def __init__(self, field): + super(FieldOnlyReducer, self).__init__(field) + self._field = field + + +class count(Reducer): + """ + Counts the number of results in the group + """ + + NAME = "COUNT" + + def __init__(self): + super(count, self).__init__() + + +class sum(FieldOnlyReducer): + """ + Calculates the sum of all the values in the given fields within the group + """ + + NAME = "SUM" + + def __init__(self, field): + super(sum, self).__init__(field) + + +class min(FieldOnlyReducer): + """ + Calculates the smallest value in the given field within the group + """ + + NAME = "MIN" + + def __init__(self, field): + super(min, self).__init__(field) + + +class max(FieldOnlyReducer): + """ + Calculates the largest value in the given field within the group + """ + + NAME = "MAX" + + def __init__(self, field): + super(max, self).__init__(field) + + +class avg(FieldOnlyReducer): + """ + Calculates the mean value in the given field within the group + """ + + NAME = "AVG" + + def __init__(self, field): + super(avg, self).__init__(field) + + +class tolist(FieldOnlyReducer): + """ + Returns all the matched properties in a list + """ + + NAME = "TOLIST" + + def __init__(self, field): + super(tolist, self).__init__(field) + + +class count_distinct(FieldOnlyReducer): + """ + Calculate the number of distinct values contained in all the results in + the group for the given field + """ + + NAME = "COUNT_DISTINCT" + + def __init__(self, field): + super(count_distinct, self).__init__(field) + + +class count_distinctish(FieldOnlyReducer): + """ + Calculate the number of distinct values contained in all the results in the + group for the given field. This uses a faster algorithm than + `count_distinct` but is less accurate + """ + + NAME = "COUNT_DISTINCTISH" + + +class quantile(Reducer): + """ + Return the value for the nth percentile within the range of values for the + field within the group. + """ + + NAME = "QUANTILE" + + def __init__(self, field, pct): + super(quantile, self).__init__(field, str(pct)) + self._field = field + + +class stddev(FieldOnlyReducer): + """ + Return the standard deviation for the values within the group + """ + + NAME = "STDDEV" + + def __init__(self, field): + super(stddev, self).__init__(field) + + +class first_value(Reducer): + """ + Selects the first value within the group according to sorting parameters + """ + + NAME = "FIRST_VALUE" + + def __init__(self, field, *byfields): + """ + Selects the first value of the given field within the group. + + ### Parameter + + - **field**: Source field used for the value + - **byfields**: How to sort the results. This can be either the + *class* of `aggregation.Asc` or `aggregation.Desc` in which + case the field `field` is also used as the sort input. + + `byfields` can also be one or more *instances* of `Asc` or `Desc` + indicating the sort order for these fields + """ + + fieldstrs = [] + if ( + len(byfields) == 1 + and isinstance(byfields[0], type) + and issubclass(byfields[0], SortDirection) + ): + byfields = [byfields[0](field)] + + for f in byfields: + fieldstrs += [f.field, f.DIRSTRING] + + args = [field] + if fieldstrs: + args += ["BY"] + fieldstrs + super(first_value, self).__init__(*args) + self._field = field + + +class random_sample(Reducer): + """ + Returns a random sample of items from the dataset, from the given property + """ + + NAME = "RANDOM_SAMPLE" + + def __init__(self, field, size): + """ + ### Parameter + + **field**: Field to sample from + **size**: Return this many items (can be less) + """ + args = [field, str(size)] + super(random_sample, self).__init__(*args) + self._field = field diff --git a/redis/commands/search/result.py b/redis/commands/search/result.py new file mode 100644 index 0000000000..afc83f87cd --- /dev/null +++ b/redis/commands/search/result.py @@ -0,0 +1,75 @@ +from six.moves import xrange, zip as izip + +from .document import Document +from ._util import to_string + + +class Result(object): + """ + Represents the result of a search query, and has an array of Document + objects + """ + + def __init__( + self, res, hascontent, duration=0, has_payload=False, with_scores=False + ): + """ + - **snippets**: An optional dictionary of the form + {field: snippet_size} for snippet formatting + """ + + self.total = res[0] + self.duration = duration + self.docs = [] + + step = 1 + if hascontent: + step = step + 1 + if has_payload: + step = step + 1 + if with_scores: + step = step + 1 + + offset = 2 if with_scores else 1 + + for i in xrange(1, len(res), step): + id = to_string(res[i]) + payload = to_string(res[i + offset]) if has_payload else None + # fields_offset = 2 if has_payload else 1 + fields_offset = offset + 1 if has_payload else offset + score = float(res[i + 1]) if with_scores else None + + fields = {} + if hascontent: + fields = ( + dict( + dict( + izip( + map(to_string, res[i + fields_offset][::2]), + map(to_string, res[i + fields_offset][1::2]), + ) + ) + ) + if hascontent + else {} + ) + try: + del fields["id"] + except KeyError: + pass + + try: + fields["json"] = fields["$"] + del fields["$"] + except KeyError: + pass + + doc = ( + Document(id, score=score, payload=payload, **fields) + if with_scores + else Document(id, payload=payload, **fields) + ) + self.docs.append(doc) + + def __repr__(self): + return "Result{%d total, docs: %s}" % (self.total, self.docs) diff --git a/redis/commands/search/suggestion.py b/redis/commands/search/suggestion.py new file mode 100644 index 0000000000..550c514823 --- /dev/null +++ b/redis/commands/search/suggestion.py @@ -0,0 +1,54 @@ +from six.moves import xrange +from ._util import to_string + + +class Suggestion(object): + """ + Represents a single suggestion being sent or returned from the + autocomplete server + """ + + def __init__(self, string, score=1.0, payload=None): + self.string = to_string(string) + self.payload = to_string(payload) + self.score = score + + def __repr__(self): + return self.string + + +class SuggestionParser(object): + """ + Internal class used to parse results from the `SUGGET` command. + This needs to consume either 1, 2, or 3 values at a time from + the return value depending on what objects were requested + """ + + def __init__(self, with_scores, with_payloads, ret): + self.with_scores = with_scores + self.with_payloads = with_payloads + + if with_scores and with_payloads: + self.sugsize = 3 + self._scoreidx = 1 + self._payloadidx = 2 + elif with_scores: + self.sugsize = 2 + self._scoreidx = 1 + elif with_payloads: + self.sugsize = 2 + self._payloadidx = 1 + else: + self.sugsize = 1 + self._scoreidx = -1 + + self._sugs = ret + + def __iter__(self): + for i in xrange(0, len(self._sugs), self.sugsize): + ss = self._sugs[i] + score = float(self._sugs[i + self._scoreidx]) \ + if self.with_scores else 1.0 + payload = self._sugs[i + self._payloadidx] \ + if self.with_payloads else None + yield Suggestion(ss, score, payload) diff --git a/tasks.py b/tasks.py index 15e983bbfd..aa965c6902 100644 --- a/tasks.py +++ b/tasks.py @@ -17,7 +17,6 @@ def devenv(c): cmd = 'tox -e devenv' for d in dockers: cmd += " --docker-dont-stop={}".format(d) - print("Running: {}".format(cmd)) run(cmd) diff --git a/tests/conftest.py b/tests/conftest.py index 9ca429dafa..47188df07f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,8 @@ default_redis_url = "redis://localhost:6379/9" default_redismod_url = "redis://localhost:36379/9" +default_redismod_url = "redis://localhost:36379" + def pytest_addoption(parser): parser.addoption('--redis-url', default=default_redis_url, @@ -85,6 +87,7 @@ def skip_ifmodversion_lt(min_version: str, module_name: str): def _get_client(cls, request, single_connection_client=True, flushdb=True, + from_url=None, **kwargs): """ Helper for fixtures or tests that need a Redis client @@ -93,7 +96,10 @@ def _get_client(cls, request, single_connection_client=True, flushdb=True, ConnectionPool.from_url, keyword arguments to this function override values specified in the URL. """ - redis_url = request.config.getoption("--redis-url") + if from_url is None: + redis_url = request.config.getoption("--redis-url") + else: + redis_url = from_url url_options = parse_url(redis_url) url_options.update(kwargs) pool = redis.ConnectionPool(**url_options) @@ -115,9 +121,12 @@ def teardown(): return client +# specifically set to the zero database, because creating +# an index on db != 0 raises a ResponseError in redis @pytest.fixture() -def modclient(request, port=36379, **kwargs): - with _get_client(redis.Redis, request, port=port, **kwargs) as client: +def modclient(request, **kwargs): + rmurl = request.config.getoption('--redismod-url') + with _get_client(redis.Redis, request, from_url=rmurl, **kwargs) as client: yield client diff --git a/tests/test_json.py b/tests/test_json.py index 96675f1e45..83fbf28669 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -10,7 +10,7 @@ def client(modclient): return modclient -@pytest.mark.json +@pytest.mark.redismod def test_json_setbinarykey(client): d = {"hello": "world", b"some": "value"} with pytest.raises(TypeError): @@ -18,7 +18,7 @@ def test_json_setbinarykey(client): assert client.json().set("somekey", Path.rootPath(), d, decode_keys=True) -@pytest.mark.json +@pytest.mark.redismod def test_json_setgetdeleteforget(client): assert client.json().set("foo", Path.rootPath(), "bar") assert client.json().get("foo") == "bar" @@ -28,13 +28,13 @@ def test_json_setgetdeleteforget(client): assert client.exists("foo") == 0 -@pytest.mark.json +@pytest.mark.redismod def test_justaget(client): client.json().set("foo", Path.rootPath(), "bar") assert client.json().get("foo") == "bar" -@pytest.mark.json +@pytest.mark.redismod def test_json_get_jset(client): assert client.json().set("foo", Path.rootPath(), "bar") assert "bar" == client.json().get("foo") @@ -43,7 +43,7 @@ def test_json_get_jset(client): assert client.exists("foo") == 0 -@pytest.mark.json +@pytest.mark.redismod def test_nonascii_setgetdelete(client): assert client.json().set("notascii", Path.rootPath(), "hyvää-élève") is True @@ -52,7 +52,7 @@ def test_nonascii_setgetdelete(client): assert client.exists("notascii") == 0 -@pytest.mark.json +@pytest.mark.redismod def test_jsonsetexistentialmodifiersshouldsucceed(client): obj = {"foo": "bar"} assert client.json().set("obj", Path.rootPath(), obj) @@ -70,7 +70,7 @@ def test_jsonsetexistentialmodifiersshouldsucceed(client): client.json().set("obj", Path("foo"), "baz", nx=True, xx=True) -@pytest.mark.json +@pytest.mark.redismod def test_mgetshouldsucceed(client): client.json().set("1", Path.rootPath(), 1) client.json().set("2", Path.rootPath(), 2) @@ -79,7 +79,7 @@ def test_mgetshouldsucceed(client): assert e == r -@pytest.mark.json +@pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "ReJSON") # todo: update after the release def test_clearShouldSucceed(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) @@ -87,13 +87,13 @@ def test_clearShouldSucceed(client): assert [] == client.json().get("arr") -@pytest.mark.json +@pytest.mark.redismod def test_typeshouldsucceed(client): client.json().set("1", Path.rootPath(), 1) assert b"integer" == client.json().type("1") -@pytest.mark.json +@pytest.mark.redismod def test_numincrbyshouldsucceed(client): client.json().set("num", Path.rootPath(), 1) assert 2 == client.json().numincrby("num", Path.rootPath(), 1) @@ -101,7 +101,7 @@ def test_numincrbyshouldsucceed(client): assert 1.25 == client.json().numincrby("num", Path.rootPath(), -1.25) -@pytest.mark.json +@pytest.mark.redismod def test_nummultbyshouldsucceed(client): client.json().set("num", Path.rootPath(), 1) assert 2 == client.json().nummultby("num", Path.rootPath(), 2) @@ -109,7 +109,7 @@ def test_nummultbyshouldsucceed(client): assert 2.5 == client.json().nummultby("num", Path.rootPath(), 0.5) -@pytest.mark.json +@pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "ReJSON") # todo: update after the release def test_toggleShouldSucceed(client): client.json().set("bool", Path.rootPath(), False) @@ -121,20 +121,20 @@ def test_toggleShouldSucceed(client): client.json().toggle("num", Path.rootPath()) -@pytest.mark.json +@pytest.mark.redismod def test_strappendshouldsucceed(client): client.json().set("str", Path.rootPath(), "foo") assert 6 == client.json().strappend("str", "bar", Path.rootPath()) assert "foobar" == client.json().get("str", Path.rootPath()) -@pytest.mark.json +@pytest.mark.redismod def test_debug(client): client.json().set("str", Path.rootPath(), "foo") assert 24 == client.json().debug("str", Path.rootPath()) -@pytest.mark.json +@pytest.mark.redismod def test_strlenshouldsucceed(client): client.json().set("str", Path.rootPath(), "foo") assert 3 == client.json().strlen("str", Path.rootPath()) @@ -142,7 +142,7 @@ def test_strlenshouldsucceed(client): assert 6 == client.json().strlen("str", Path.rootPath()) -@pytest.mark.json +@pytest.mark.redismod def test_arrappendshouldsucceed(client): client.json().set("arr", Path.rootPath(), [1]) assert 2 == client.json().arrappend("arr", Path.rootPath(), 2) @@ -150,14 +150,14 @@ def test_arrappendshouldsucceed(client): assert 7 == client.json().arrappend("arr", Path.rootPath(), *[5, 6, 7]) -@pytest.mark.json +@pytest.mark.redismod def testArrIndexShouldSucceed(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 1 == client.json().arrindex("arr", Path.rootPath(), 1) assert -1 == client.json().arrindex("arr", Path.rootPath(), 1, 2) -@pytest.mark.json +@pytest.mark.redismod def test_arrinsertshouldsucceed(client): client.json().set("arr", Path.rootPath(), [0, 4]) assert 5 - -client.json().arrinsert( @@ -173,13 +173,13 @@ def test_arrinsertshouldsucceed(client): assert [0, 1, 2, 3, 4] == client.json().get("arr") -@pytest.mark.json +@pytest.mark.redismod def test_arrlenshouldsucceed(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 5 == client.json().arrlen("arr", Path.rootPath()) -@pytest.mark.json +@pytest.mark.redismod def test_arrpopshouldsucceed(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 4 == client.json().arrpop("arr", Path.rootPath(), 4) @@ -189,14 +189,14 @@ def test_arrpopshouldsucceed(client): assert [1] == client.json().get("arr") -@pytest.mark.json +@pytest.mark.redismod def test_arrtrimshouldsucceed(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 3 == client.json().arrtrim("arr", Path.rootPath(), 1, 3) assert [1, 2, 3] == client.json().get("arr") -@pytest.mark.json +@pytest.mark.redismod def test_respshouldsucceed(client): obj = {"foo": "bar", "baz": 1, "qaz": True} client.json().set("obj", Path.rootPath(), obj) @@ -205,7 +205,7 @@ def test_respshouldsucceed(client): assert client.json().resp("obj", Path("qaz")) -@pytest.mark.json +@pytest.mark.redismod def test_objkeysshouldsucceed(client): obj = {"foo": "bar", "baz": "qaz"} client.json().set("obj", Path.rootPath(), obj) @@ -216,7 +216,7 @@ def test_objkeysshouldsucceed(client): assert exp == keys -@pytest.mark.json +@pytest.mark.redismod def test_objlenshouldsucceed(client): obj = {"foo": "bar", "baz": "qaz"} client.json().set("obj", Path.rootPath(), obj) @@ -224,7 +224,7 @@ def test_objlenshouldsucceed(client): # @pytest.mark.pipeline -# @pytest.mark.json +# @pytest.mark.redismod # def test_pipelineshouldsucceed(client): # p = client.json().pipeline() # p.set("foo", Path.rootPath(), "bar") diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000000..926b5ff3af --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,1219 @@ +import pytest +import redis +import bz2 +import csv +import time +import os + +from io import TextIOWrapper +from .conftest import skip_ifmodversion_lt, default_redismod_url +from redis import Redis + +import redis.commands.search +from redis.commands.json.path import Path +from redis.commands.search import Search +from redis.commands.search.field import ( + GeoField, + NumericField, + TagField, + TextField +) +from redis.commands.search.query import ( + GeoFilter, + NumericFilter, + Query +) +from redis.commands.search.result import Result +from redis.commands.search.indexDefinition import IndexDefinition, IndexType +from redis.commands.search.suggestion import Suggestion +import redis.commands.search.aggregation as aggregations +import redis.commands.search.reducers as reducers + +WILL_PLAY_TEXT = ( + os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "testdata", + "will_play_text.csv.bz2" + ) + ) +) + +TITLES_CSV = ( + os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "testdata", + "titles.csv" + ) + ) +) + + +def waitForIndex(env, idx, timeout=None): + delay = 0.1 + while True: + res = env.execute_command("ft.info", idx) + try: + res.index("indexing") + except ValueError: + break + + if int(res[res.index("indexing") + 1]) == 0: + break + + time.sleep(delay) + if timeout is not None: + timeout -= delay + if timeout <= 0: + break + + +def getClient(): + """ + Gets a client client attached to an index name which is ready to be + created + """ + rc = Redis.from_url(default_redismod_url, decode_responses=True) + return rc + + +def createIndex(client, num_docs=100, definition=None): + try: + client.create_index( + (TextField("play", weight=5.0), + TextField("txt"), + NumericField("chapter")), + definition=definition, + ) + except redis.ResponseError: + client.dropindex(delete_documents=True) + return createIndex(client, num_docs=num_docs, definition=definition) + + chapters = {} + bzfp = TextIOWrapper(bz2.BZ2File(WILL_PLAY_TEXT), encoding="utf8") + + r = csv.reader(bzfp, delimiter=";") + for n, line in enumerate(r): + + play, chapter, _, text = \ + line[1], line[2], line[4], line[5] + + key = "{}:{}".format(play, chapter).lower() + d = chapters.setdefault(key, {}) + d["play"] = play + d["txt"] = d.get("txt", "") + " " + text + d["chapter"] = int(chapter or 0) + if len(chapters) == num_docs: + break + + indexer = client.batch_indexer(chunk_size=50) + assert isinstance(indexer, Search.BatchIndexer) + assert 50 == indexer.chunk_size + + for key, doc in chapters.items(): + indexer.add_document(key, **doc) + indexer.commit() + + +# override the default module client, search requires both db=0, and text +@pytest.fixture +def modclient(): + return Redis.from_url(default_redismod_url, db=0, decode_responses=True) + + +@pytest.fixture +def client(modclient): + modclient.flushdb() + return modclient + + +@pytest.mark.redismod +def test_client(client): + num_docs = 500 + createIndex(client.ft(), num_docs=num_docs) + waitForIndex(client, "idx") + # verify info + info = client.ft().info() + for k in [ + "index_name", + "index_options", + "attributes", + "num_docs", + "max_doc_id", + "num_terms", + "num_records", + "inverted_sz_mb", + "offset_vectors_sz_mb", + "doc_table_size_mb", + "key_table_size_mb", + "records_per_doc_avg", + "bytes_per_record_avg", + "offsets_per_term_avg", + "offset_bits_per_record_avg", + ]: + assert k in info + + assert client.ft().index_name == info["index_name"] + assert num_docs == int(info["num_docs"]) + + res = client.ft().search("henry iv") + assert isinstance(res, Result) + assert 225 == res.total + assert 10 == len(res.docs) + assert res.duration > 0 + + for doc in res.docs: + assert doc.id + assert doc.play == "Henry IV" + assert len(doc.txt) > 0 + + # test no content + res = client.ft().search(Query("king").no_content()) + assert 194 == res.total + assert 10 == len(res.docs) + for doc in res.docs: + assert "txt" not in doc.__dict__ + assert "play" not in doc.__dict__ + + # test verbatim vs no verbatim + total = client.ft().search(Query("kings").no_content()).total + vtotal = client.ft().search(Query("kings").no_content().verbatim()).total + assert total > vtotal + + # test in fields + txt_total = ( + client.ft().search( + Query("henry").no_content().limit_fields("txt")).total + ) + play_total = ( + client.ft().search( + Query("henry").no_content().limit_fields("play")).total + ) + both_total = ( + client.ft() + .search(Query("henry").no_content().limit_fields("play", "txt")) + .total + ) + assert 129 == txt_total + assert 494 == play_total + assert 494 == both_total + + # test load_document + doc = client.ft().load_document("henry vi part 3:62") + assert doc is not None + assert "henry vi part 3:62" == doc.id + assert doc.play == "Henry VI Part 3" + assert len(doc.txt) > 0 + + # test in-keys + ids = [x.id for x in client.ft().search(Query("henry")).docs] + assert 10 == len(ids) + subset = ids[:5] + docs = client.ft().search(Query("henry").limit_ids(*subset)) + assert len(subset) == docs.total + ids = [x.id for x in docs.docs] + assert set(ids) == set(subset) + + # test slop and in order + assert 193 == client.ft().search(Query("henry king")).total + assert 3 == client.ft().search( + Query("henry king").slop(0).in_order()).total + assert 52 == client.ft().search( + Query("king henry").slop(0).in_order()).total + assert 53 == client.ft().search(Query("henry king").slop(0)).total + assert 167 == client.ft().search(Query("henry king").slop(100)).total + + # test delete document + client.ft().add_document("doc-5ghs2", play="Death of a Salesman") + res = client.ft().search(Query("death of a salesman")) + assert 1 == res.total + + assert 1 == client.ft().delete_document("doc-5ghs2") + res = client.ft().search(Query("death of a salesman")) + assert 0 == res.total + assert 0 == client.ft().delete_document("doc-5ghs2") + + client.ft().add_document("doc-5ghs2", play="Death of a Salesman") + res = client.ft().search(Query("death of a salesman")) + assert 1 == res.total + client.ft().delete_document("doc-5ghs2") + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.2.0", "search") +def test_payloads(client): + client.ft().create_index((TextField("txt"),)) + + client.ft().add_document("doc1", payload="foo baz", txt="foo bar") + client.ft().add_document("doc2", txt="foo bar") + + q = Query("foo bar").with_payloads() + res = client.ft().search(q) + assert 2 == res.total + assert "doc1" == res.docs[0].id + assert "doc2" == res.docs[1].id + assert "foo baz" == res.docs[0].payload + assert res.docs[1].payload is None + + +@pytest.mark.redismod +def test_scores(client): + client.ft().create_index((TextField("txt"),)) + + client.ft().add_document("doc1", txt="foo baz") + client.ft().add_document("doc2", txt="foo bar") + + q = Query("foo ~bar").with_scores() + res = client.ft().search(q) + assert 2 == res.total + assert "doc2" == res.docs[0].id + assert 3.0 == res.docs[0].score + assert "doc1" == res.docs[1].id + # todo: enable once new RS version is tagged + # self.assertEqual(0.2, res.docs[1].score) + + +@pytest.mark.redismod +def test_replace(client): + client.ft().create_index((TextField("txt"),)) + + client.ft().add_document("doc1", txt="foo bar") + client.ft().add_document("doc2", txt="foo bar") + waitForIndex(client, "idx") + + res = client.ft().search("foo bar") + assert 2 == res.total + client.ft().add_document( + "doc1", + replace=True, + txt="this is a replaced doc" + ) + + res = client.ft().search("foo bar") + assert 1 == res.total + assert "doc2" == res.docs[0].id + + res = client.ft().search("replaced doc") + assert 1 == res.total + assert "doc1" == res.docs[0].id + + +@pytest.mark.redismod +def test_stopwords(client): + client.ft().create_index( + (TextField("txt"),), + stopwords=["foo", "bar", "baz"] + ) + client.ft().add_document("doc1", txt="foo bar") + client.ft().add_document("doc2", txt="hello world") + waitForIndex(client, "idx") + + q1 = Query("foo bar").no_content() + q2 = Query("foo bar hello world").no_content() + res1, res2 = client.ft().search(q1), client.ft().search(q2) + assert 0 == res1.total + assert 1 == res2.total + + +@pytest.mark.redismod +def test_filters(client): + client.ft().create_index( + (TextField("txt"), + NumericField("num"), + GeoField("loc")) + ) + client.ft().add_document( + "doc1", + txt="foo bar", + num=3.141, + loc="-0.441,51.458" + ) + client.ft().add_document("doc2", txt="foo baz", num=2, loc="-0.1,51.2") + + waitForIndex(client, "idx") + # Test numerical filter + q1 = Query("foo").add_filter(NumericFilter("num", 0, 2)).no_content() + q2 = ( + Query("foo") + .add_filter( + NumericFilter("num", 2, NumericFilter.INF, minExclusive=True)) + .no_content() + ) + res1, res2 = client.ft().search(q1), client.ft().search(q2) + + assert 1 == res1.total + assert 1 == res2.total + assert "doc2" == res1.docs[0].id + assert "doc1" == res2.docs[0].id + + # Test geo filter + q1 = Query("foo").add_filter( + GeoFilter("loc", -0.44, 51.45, 10)).no_content() + q2 = Query("foo").add_filter( + GeoFilter("loc", -0.44, 51.45, 100)).no_content() + res1, res2 = client.ft().search(q1), client.ft().search(q2) + + assert 1 == res1.total + assert 2 == res2.total + assert "doc1" == res1.docs[0].id + + # Sort results, after RDB reload order may change + res = [res2.docs[0].id, res2.docs[1].id] + res.sort() + assert ["doc1", "doc2"] == res + + +@pytest.mark.redismod +def test_payloads_with_no_content(client): + client.ft().create_index((TextField("txt"),)) + client.ft().add_document("doc1", payload="foo baz", txt="foo bar") + client.ft().add_document("doc2", payload="foo baz2", txt="foo bar") + + q = Query("foo bar").with_payloads().no_content() + res = client.ft().search(q) + assert 2 == len(res.docs) + + +@pytest.mark.redismod +def test_sort_by(client): + client.ft().create_index( + (TextField("txt"), + NumericField("num", sortable=True)) + ) + client.ft().add_document("doc1", txt="foo bar", num=1) + client.ft().add_document("doc2", txt="foo baz", num=2) + client.ft().add_document("doc3", txt="foo qux", num=3) + + # Test sort + q1 = Query("foo").sort_by("num", asc=True).no_content() + q2 = Query("foo").sort_by("num", asc=False).no_content() + res1, res2 = client.ft().search(q1), client.ft().search(q2) + + assert 3 == res1.total + assert "doc1" == res1.docs[0].id + assert "doc2" == res1.docs[1].id + assert "doc3" == res1.docs[2].id + assert 3 == res2.total + assert "doc1" == res2.docs[2].id + assert "doc2" == res2.docs[1].id + assert "doc3" == res2.docs[0].id + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.0.0", "search") +def test_drop_index(): + """ + Ensure the index gets dropped by data remains by default + """ + for x in range(20): + for keep_docs in [[True, {}], [False, {"name": "haveit"}]]: + idx = "HaveIt" + index = getClient() + index.hset("index:haveit", mapping={"name": "haveit"}) + idef = IndexDefinition(prefix=["index:"]) + index.ft(idx).create_index((TextField("name"),), definition=idef) + waitForIndex(index, idx) + index.ft(idx).dropindex(delete_documents=keep_docs[0]) + i = index.hgetall("index:haveit") + assert i == keep_docs[1] + + +@pytest.mark.redismod +def test_example(client): + # Creating the index definition and schema + client.ft().create_index( + (TextField("title", weight=5.0), + TextField("body")) + ) + + # Indexing a document + client.ft().add_document( + "doc1", + title="RediSearch", + body="Redisearch impements a search engine on top of redis", + ) + + # Searching with complex parameters: + q = Query("search engine").verbatim().no_content().paging(0, 5) + + res = client.ft().search(q) + assert res is not None + + +@pytest.mark.redismod +def test_auto_complete(client): + n = 0 + with open(TITLES_CSV) as f: + cr = csv.reader(f) + + for row in cr: + n += 1 + term, score = row[0], float(row[1]) + assert n == client.ft().sugadd("ac", Suggestion(term, score=score)) + + assert n == client.ft().suglen("ac") + ret = client.ft().sugget("ac", "bad", with_scores=True) + assert 2 == len(ret) + assert "badger" == ret[0].string + assert isinstance(ret[0].score, float) + assert 1.0 != ret[0].score + assert "badalte rishtey" == ret[1].string + assert isinstance(ret[1].score, float) + assert 1.0 != ret[1].score + + ret = client.ft().sugget("ac", "bad", fuzzy=True, num=10) + assert 10 == len(ret) + assert 1.0 == ret[0].score + strs = {x.string for x in ret} + + for sug in strs: + assert 1 == client.ft().sugdel("ac", sug) + # make sure a second delete returns 0 + for sug in strs: + assert 0 == client.ft().sugdel("ac", sug) + + # make sure they were actually deleted + ret2 = client.ft().sugget("ac", "bad", fuzzy=True, num=10) + for sug in ret2: + assert sug.string not in strs + + # Test with payload + client.ft().sugadd("ac", Suggestion("pay1", payload="pl1")) + client.ft().sugadd("ac", Suggestion("pay2", payload="pl2")) + client.ft().sugadd("ac", Suggestion("pay3", payload="pl3")) + + sugs = client.ft().sugget( + "ac", + "pay", + with_payloads=True, + with_scores=True + ) + assert 3 == len(sugs) + for sug in sugs: + assert sug.payload + assert sug.payload.startswith("pl") + + +@pytest.mark.redismod +def test_no_index(client): + client.ft().create_index( + ( + TextField("field"), + TextField("text", no_index=True, sortable=True), + NumericField("numeric", no_index=True, sortable=True), + GeoField("geo", no_index=True, sortable=True), + TagField("tag", no_index=True, sortable=True), + ) + ) + + client.ft().add_document( + "doc1", field="aaa", text="1", numeric="1", geo="1,1", tag="1" + ) + client.ft().add_document( + "doc2", field="aab", text="2", numeric="2", geo="2,2", tag="2" + ) + waitForIndex(client, "idx") + + res = client.ft().search(Query("@text:aa*")) + assert 0 == res.total + + res = client.ft().search(Query("@field:aa*")) + assert 2 == res.total + + res = client.ft().search(Query("*").sort_by("text", asc=False)) + assert 2 == res.total + assert "doc2" == res.docs[0].id + + res = client.ft().search(Query("*").sort_by("text", asc=True)) + assert "doc1" == res.docs[0].id + + res = client.ft().search(Query("*").sort_by("numeric", asc=True)) + assert "doc1" == res.docs[0].id + + res = client.ft().search(Query("*").sort_by("geo", asc=True)) + assert "doc1" == res.docs[0].id + + res = client.ft().search(Query("*").sort_by("tag", asc=True)) + assert "doc1" == res.docs[0].id + + # Ensure exception is raised for non-indexable, non-sortable fields + with pytest.raises(Exception): + TextField("name", no_index=True, sortable=False) + with pytest.raises(Exception): + NumericField("name", no_index=True, sortable=False) + with pytest.raises(Exception): + GeoField("name", no_index=True, sortable=False) + with pytest.raises(Exception): + TagField("name", no_index=True, sortable=False) + + +@pytest.mark.redismod +def test_partial(client): + client.ft().create_index( + (TextField("f1"), + TextField("f2"), + TextField("f3")) + ) + client.ft().add_document("doc1", f1="f1_val", f2="f2_val") + client.ft().add_document("doc2", f1="f1_val", f2="f2_val") + client.ft().add_document("doc1", f3="f3_val", partial=True) + client.ft().add_document("doc2", f3="f3_val", replace=True) + waitForIndex(client, "idx") + + # Search for f3 value. All documents should have it + res = client.ft().search("@f3:f3_val") + assert 2 == res.total + + # Only the document updated with PARTIAL should still have f1 and f2 values + res = client.ft().search("@f3:f3_val @f2:f2_val @f1:f1_val") + assert 1 == res.total + + +@pytest.mark.redismod +def test_no_create(client): + client.ft().create_index( + (TextField("f1"), + TextField("f2"), + TextField("f3")) + ) + client.ft().add_document("doc1", f1="f1_val", f2="f2_val") + client.ft().add_document("doc2", f1="f1_val", f2="f2_val") + client.ft().add_document("doc1", f3="f3_val", no_create=True) + client.ft().add_document("doc2", f3="f3_val", no_create=True, partial=True) + waitForIndex(client, "idx") + + # Search for f3 value. All documents should have it + res = client.ft().search("@f3:f3_val") + assert 2 == res.total + + # Only the document updated with PARTIAL should still have f1 and f2 values + res = client.ft().search("@f3:f3_val @f2:f2_val @f1:f1_val") + assert 1 == res.total + + with pytest.raises(redis.ResponseError): + client.ft().add_document( + "doc3", + f2="f2_val", + f3="f3_val", + no_create=True + ) + + +@pytest.mark.redismod +def test_explain(client): + client.ft().create_index( + (TextField("f1"), + TextField("f2"), + TextField("f3")) + ) + res = client.ft().explain("@f3:f3_val @f2:f2_val @f1:f1_val") + assert res + + +@pytest.mark.redismod +def test_summarize(client): + createIndex(client.ft()) + waitForIndex(client, "idx") + + q = Query("king henry").paging(0, 1) + q.highlight(fields=("play", "txt"), tags=("", "")) + q.summarize("txt") + + doc = sorted(client.ft().search(q).docs)[0] + assert "Henry IV" == doc.play + assert ( + "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa + == doc.txt + ) + + q = Query("king henry").paging(0, 1).summarize().highlight() + + doc = sorted(client.ft().search(q).docs)[0] + assert "Henry ... " == doc.play + assert ( + "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa + == doc.txt + ) + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.0.0", "search") +def test_alias(): + index1 = getClient() + index2 = getClient() + + index1.hset("index1:lonestar", mapping={"name": "lonestar"}) + index2.hset("index2:yogurt", mapping={"name": "yogurt"}) + + if os.environ.get("GITHUB_WORKFLOW", None) is not None: + time.sleep(2) + else: + time.sleep(5) + + def1 = IndexDefinition(prefix=["index1:"], score_field="name") + def2 = IndexDefinition(prefix=["index2:"], score_field="name") + + ftindex1 = index1.ft("testAlias") + ftindex2 = index1.ft("testAlias2") + ftindex1.create_index((TextField("name"),), definition=def1) + ftindex2.create_index((TextField("name"),), definition=def2) + + # CI is slower + try: + res = ftindex1.search("*").docs[0] + except IndexError: + time.sleep(5) + res = ftindex1.search("*").docs[0] + assert "index1:lonestar" == res.id + + # create alias and check for results + ftindex1.aliasadd("spaceballs") + alias_client = getClient().ft("spaceballs") + res = alias_client.search("*").docs[0] + assert "index1:lonestar" == res.id + + # Throw an exception when trying to add an alias that already exists + with pytest.raises(Exception): + ftindex2.aliasadd("spaceballs") + + # update alias and ensure new results + ftindex2.aliasupdate("spaceballs") + alias_client2 = getClient().ft("spaceballs") + res = alias_client2.search("*").docs[0] + assert "index2:yogurt" == res.id + + ftindex2.aliasdel("spaceballs") + with pytest.raises(Exception): + alias_client2.search("*").docs[0] + + +@pytest.mark.redismod +def test_alias_basic(): + # Creating a client with one index + getClient().flushdb() + index1 = getClient().ft("testAlias") + + index1.create_index((TextField("txt"),)) + index1.add_document("doc1", txt="text goes here") + + index2 = getClient().ft("testAlias2") + index2.create_index((TextField("txt"),)) + index2.add_document("doc2", txt="text goes here") + + # add the actual alias and check + index1.aliasadd("myalias") + alias_client = getClient().ft("myalias") + res = sorted(alias_client.search("*").docs, key=lambda x: x.id) + assert "doc1" == res[0].id + + # Throw an exception when trying to add an alias that already exists + with pytest.raises(Exception): + index2.aliasadd("myalias") + + # update the alias and ensure we get doc2 + index2.aliasupdate("myalias") + alias_client2 = getClient().ft("myalias") + res = sorted(alias_client2.search("*").docs, key=lambda x: x.id) + assert "doc1" == res[0].id + + # delete the alias and expect an error if we try to query again + index2.aliasdel("myalias") + with pytest.raises(Exception): + _ = alias_client2.search("*").docs[0] + + +@pytest.mark.redismod +def test_tags(client): + client.ft().create_index((TextField("txt"), TagField("tags"))) + tags = "foo,foo bar,hello;world" + tags2 = "soba,ramen" + + client.ft().add_document("doc1", txt="fooz barz", tags=tags) + client.ft().add_document("doc2", txt="noodles", tags=tags2) + waitForIndex(client, "idx") + + q = Query("@tags:{foo}") + res = client.ft().search(q) + assert 1 == res.total + + q = Query("@tags:{foo bar}") + res = client.ft().search(q) + assert 1 == res.total + + q = Query("@tags:{foo\\ bar}") + res = client.ft().search(q) + assert 1 == res.total + + q = Query("@tags:{hello\\;world}") + res = client.ft().search(q) + assert 1 == res.total + + q2 = client.ft().tagvals("tags") + assert (tags.split(",") + tags2.split(",")).sort() == q2.sort() + + +@pytest.mark.redismod +def test_textfield_sortable_nostem(client): + # Creating the index definition with sortable and no_stem + client.ft().create_index((TextField("txt", sortable=True, no_stem=True),)) + + # Now get the index info to confirm its contents + response = client.ft().info() + assert "SORTABLE" in response["attributes"][0] + assert "NOSTEM" in response["attributes"][0] + + +@pytest.mark.redismod +def test_alter_schema_add(client): + # Creating the index definition and schema + client.ft().create_index(TextField("title")) + + # Using alter to add a field + client.ft().alter_schema_add(TextField("body")) + + # Indexing a document + client.ft().add_document( + "doc1", title="MyTitle", body="Some content only in the body" + ) + + # Searching with parameter only in the body (the added field) + q = Query("only in the body") + + # Ensure we find the result searching on the added body field + res = client.ft().search(q) + assert 1 == res.total + + +@pytest.mark.redismod +def test_spell_check(client): + client.ft().create_index((TextField("f1"), TextField("f2"))) + + client.ft().add_document( + "doc1", + f1="some valid content", + f2="this is sample text" + ) + client.ft().add_document("doc2", f1="very important", f2="lorem ipsum") + waitForIndex(client, "idx") + + # test spellcheck + res = client.ft().spellcheck("impornant") + assert "important" == res["impornant"][0]["suggestion"] + + res = client.ft().spellcheck("contnt") + assert "content" == res["contnt"][0]["suggestion"] + + # test spellcheck with Levenshtein distance + res = client.ft().spellcheck("vlis") + assert res == {} + res = client.ft().spellcheck("vlis", distance=2) + assert "valid" == res["vlis"][0]["suggestion"] + + # test spellcheck include + client.ft().dict_add("dict", "lore", "lorem", "lorm") + res = client.ft().spellcheck("lorm", include="dict") + assert len(res["lorm"]) == 3 + assert ( + res["lorm"][0]["suggestion"], + res["lorm"][1]["suggestion"], + res["lorm"][2]["suggestion"], + ) == ("lorem", "lore", "lorm") + assert (res["lorm"][0]["score"], res["lorm"][1]["score"]) == ("0.5", "0") + + # test spellcheck exclude + res = client.ft().spellcheck("lorm", exclude="dict") + assert res == {} + + +@pytest.mark.redismod +def test_dict_operations(client): + client.ft().create_index((TextField("f1"), TextField("f2"))) + # Add three items + res = client.ft().dict_add("custom_dict", "item1", "item2", "item3") + assert 3 == res + + # Remove one item + res = client.ft().dict_del("custom_dict", "item2") + assert 1 == res + + # Dump dict and inspect content + res = client.ft().dict_dump("custom_dict") + assert ["item1", "item3"] == res + + # Remove rest of the items before reload + client.ft().dict_del("custom_dict", *res) + + +@pytest.mark.redismod +def test_phonetic_matcher(client): + client.ft().create_index((TextField("name"),)) + client.ft().add_document("doc1", name="Jon") + client.ft().add_document("doc2", name="John") + + res = client.ft().search(Query("Jon")) + assert 1 == len(res.docs) + assert "Jon" == res.docs[0].name + + # Drop and create index with phonetic matcher + client.flushdb() + + client.ft().create_index((TextField("name", phonetic_matcher="dm:en"),)) + client.ft().add_document("doc1", name="Jon") + client.ft().add_document("doc2", name="John") + + res = client.ft().search(Query("Jon")) + assert 2 == len(res.docs) + assert ["John", "Jon"] == sorted([d.name for d in res.docs]) + + +@pytest.mark.redismod +def test_scorer(client): + client.ft().create_index((TextField("description"),)) + + client.ft().add_document( + "doc1", description="The quick brown fox jumps over the lazy dog" + ) + client.ft().add_document( + "doc2", + description="Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.", # noqa + ) + + # default scorer is TFIDF + res = client.ft().search(Query("quick").with_scores()) + assert 1.0 == res.docs[0].score + res = client.ft().search(Query("quick").scorer("TFIDF").with_scores()) + assert 1.0 == res.docs[0].score + res = client.ft().search( + Query("quick").scorer("TFIDF.DOCNORM").with_scores()) + assert 0.1111111111111111 == res.docs[0].score + res = client.ft().search(Query("quick").scorer("BM25").with_scores()) + assert 0.17699114465425977 == res.docs[0].score + res = client.ft().search(Query("quick").scorer("DISMAX").with_scores()) + assert 2.0 == res.docs[0].score + res = client.ft().search(Query("quick").scorer("DOCSCORE").with_scores()) + assert 1.0 == res.docs[0].score + res = client.ft().search(Query("quick").scorer("HAMMING").with_scores()) + assert 0.0 == res.docs[0].score + + +@pytest.mark.redismod +def test_get(client): + client.ft().create_index((TextField("f1"), TextField("f2"))) + + assert [None] == client.ft().get("doc1") + assert [None, None] == client.ft().get("doc2", "doc1") + + client.ft().add_document( + "doc1", f1="some valid content dd1", f2="this is sample text ff1" + ) + client.ft().add_document( + "doc2", f1="some valid content dd2", f2="this is sample text ff2" + ) + + assert [ + ["f1", "some valid content dd2", "f2", "this is sample text ff2"] + ] == client.ft().get("doc2") + assert [ + ["f1", "some valid content dd1", "f2", "this is sample text ff1"], + ["f1", "some valid content dd2", "f2", "this is sample text ff2"], + ] == client.ft().get("doc1", "doc2") + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.2.0", "search") +def test_config(client): + assert client.ft().config_set("TIMEOUT", "100") + with pytest.raises(redis.ResponseError): + client.ft().config_set("TIMEOUT", "null") + res = client.ft().config_get("*") + assert "100" == res["TIMEOUT"] + res = client.ft().config_get("TIMEOUT") + assert "100" == res["TIMEOUT"] + + +@pytest.mark.redismod +def test_aggregations(client): + # Creating the index definition and schema + client.ft().create_index( + ( + NumericField("random_num"), + TextField("title"), + TextField("body"), + TextField("parent"), + ) + ) + + # Indexing a document + client.ft().add_document( + "search", + title="RediSearch", + body="Redisearch impements a search engine on top of redis", + parent="redis", + random_num=10, + ) + client.ft().add_document( + "ai", + title="RedisAI", + body="RedisAI executes Deep Learning/Machine Learning models and managing their data.", # noqa + parent="redis", + random_num=3, + ) + client.ft().add_document( + "json", + title="RedisJson", + body="RedisJSON implements ECMA-404 The JSON Data Interchange Standard as a native data type.", # noqa + parent="redis", + random_num=8, + ) + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", + reducers.count(), + reducers.count_distinct("@title"), + reducers.count_distinctish("@title"), + reducers.sum("@random_num"), + reducers.min("@random_num"), + reducers.max("@random_num"), + reducers.avg("@random_num"), + reducers.stddev("random_num"), + reducers.quantile("@random_num", 0.5), + reducers.tolist("@title"), + reducers.first_value("@title"), + reducers.random_sample("@title", 2), + ) + + res = client.ft().aggregate(req) + + res = res.rows[0] + assert len(res) == 26 + assert "redis" == res[1] + assert "3" == res[3] + assert "3" == res[5] + assert "3" == res[7] + assert "21" == res[9] + assert "3" == res[11] + assert "10" == res[13] + assert "7" == res[15] + assert "3.60555127546" == res[17] + assert "10" == res[19] + assert ["RediSearch", "RedisAI", "RedisJson"] == res[21] + assert "RediSearch" == res[23] + assert 2 == len(res[25]) + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.0.0", "search") +def test_index_definition(client): + """ + Create definition and test its args + """ + with pytest.raises(RuntimeError): + IndexDefinition(prefix=["hset:", "henry"], index_type="json") + + definition = IndexDefinition( + prefix=["hset:", "henry"], + filter="@f1==32", + language="English", + language_field="play", + score_field="chapter", + score=0.5, + payload_field="txt", + index_type=IndexType.JSON, + ) + + assert [ + "ON", + "JSON", + "PREFIX", + 2, + "hset:", + "henry", + "FILTER", + "@f1==32", + "LANGUAGE_FIELD", + "play", + "LANGUAGE", + "English", + "SCORE_FIELD", + "chapter", + "SCORE", + 0.5, + "PAYLOAD_FIELD", + "txt", + ] == definition.args + + createIndex(client.ft(), num_docs=500, definition=definition) + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.0.0", "search") +def test_create_client_definition(client): + """ + Create definition with no index type provided, + and use hset to test the client definition (the default is HASH). + """ + definition = IndexDefinition(prefix=["hset:", "henry"]) + createIndex(client.ft(), num_docs=500, definition=definition) + + info = client.ft().info() + assert 494 == int(info["num_docs"]) + + client.ft().client.hset("hset:1", "f1", "v1") + info = client.ft().info() + assert 495 == int(info["num_docs"]) + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.0.0", "search") +def test_create_client_definition_hash(client): + """ + Create definition with IndexType.HASH as index type (ON HASH), + and use hset to test the client definition. + """ + definition = IndexDefinition( + prefix=["hset:", "henry"], + index_type=IndexType.HASH + ) + createIndex(client.ft(), num_docs=500, definition=definition) + + info = client.ft().info() + assert 494 == int(info["num_docs"]) + + client.ft().client.hset("hset:1", "f1", "v1") + info = client.ft().info() + assert 495 == int(info["num_docs"]) + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.2.0", "search") +def test_create_client_definition_json(client): + """ + Create definition with IndexType.JSON as index type (ON JSON), + and use json client to test it. + """ + definition = IndexDefinition(prefix=["king:"], index_type=IndexType.JSON) + client.ft().create_index((TextField("$.name"),), definition=definition) + + client.json().set("king:1", Path.rootPath(), {"name": "henry"}) + client.json().set("king:2", Path.rootPath(), {"name": "james"}) + + res = client.ft().search("henry") + assert res.docs[0].id == "king:1" + assert res.docs[0].payload is None + assert res.docs[0].json == '{"name":"henry"}' + assert res.total == 1 + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.2.0", "search") +def test_fields_as_name(client): + # create index + SCHEMA = ( + TextField("$.name", sortable=True, as_name="name"), + NumericField("$.age", as_name="just_a_number"), + ) + definition = IndexDefinition(index_type=IndexType.JSON) + client.ft().create_index(SCHEMA, definition=definition) + + # insert json data + res = client.json().set( + "doc:1", + Path.rootPath(), + {"name": "Jon", "age": 25} + ) + assert res + + total = client.ft().search( + Query("Jon").return_fields("name", "just_a_number")).docs + assert 1 == len(total) + assert "doc:1" == total[0].id + assert "Jon" == total[0].name + assert "25" == total[0].just_a_number + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.2.0", "search") +def test_search_return_fields(client): + res = client.json().set( + "doc:1", + Path.rootPath(), + {"t": "riceratops", "t2": "telmatosaurus", "n": 9072, "flt": 97.2}, + ) + assert res + + # create index on + definition = IndexDefinition(index_type=IndexType.JSON) + SCHEMA = ( + TextField("$.t"), + NumericField("$.flt"), + ) + client.ft().create_index(SCHEMA, definition=definition) + waitForIndex(client, "idx") + + total = client.ft().search( + Query("*").return_field("$.t", as_field="txt")).docs + assert 1 == len(total) + assert "doc:1" == total[0].id + assert "riceratops" == total[0].txt + + total = client.ft().search( + Query("*").return_field("$.t2", as_field="txt")).docs + assert 1 == len(total) + assert "doc:1" == total[0].id + assert "telmatosaurus" == total[0].txt + + +@pytest.mark.redismod +def test_synupdate(client): + definition = IndexDefinition(index_type=IndexType.HASH) + client.ft().create_index( + ( + TextField("title"), + TextField("body"), + ), + definition=definition, + ) + + client.ft().synupdate("id1", True, "boy", "child", "offspring") + client.ft().add_document( + "doc1", + title="he is a baby", + body="this is a test") + + client.ft().synupdate("id1", True, "baby") + client.ft().add_document( + "doc2", + title="he is another baby", + body="another test" + ) + + res = client.ft().search(Query("child").expander("SYNONYM")) + assert res.docs[0].id == "doc2" + assert res.docs[0].title == "he is another baby" + assert res.docs[0].body == "another test" + + +@pytest.mark.redismod +def test_syndump(client): + definition = IndexDefinition(index_type=IndexType.HASH) + client.ft().create_index( + ( + TextField("title"), + TextField("body"), + ), + definition=definition, + ) + + client.ft().synupdate("id1", False, "boy", "child", "offspring") + client.ft().synupdate("id2", False, "baby", "child") + client.ft().synupdate("id3", False, "tree", "wood") + res = client.ft().syndump() + assert res == { + "boy": ["id1"], + "tree": ["id3"], + "wood": ["id3"], + "child": ["id1", "id2"], + "baby": ["id2"], + "offspring": ["id1"], + } diff --git a/tests/testdata/titles.csv b/tests/testdata/titles.csv new file mode 100644 index 0000000000..6428dd2aa3 --- /dev/null +++ b/tests/testdata/titles.csv @@ -0,0 +1,4861 @@ +bhoj shala,1 +radhika balakrishnan,1 +ltm,1 +sterlite energy,1 +troll doll,11 +sonnontio,1 +nickelodeon netherlands kids choice awards,1 +jamaica national basketball team,5 +clan mackenzie,1 +secure attention key,3 +template talk indo pakistani war of 1971,1 +hassan firouzabadi,2 +carter alan,1 +alan levy,1 +tim severin,2 +faux pas derived from chinese pronunciation,1 +jruby,3 +tobias nielsén,1 +avro 571 buffalo,1 +treasury stock,17 +שלום,10 +oxygen 19,1 +ntru,4 +tennis racquet,1 +place of birth,4 +council of canadians,1 +urshu,1 +american hotel,1 +dow corning corporation,3 +language based learning disability,3 +meri aashiqui tum se hi,30 +specificity,9 +edward l hedden,1 +pelli chesukundam,2 +of love and shadows,4 +fort san felipe,2 +american express gold card dress of lizzy gardiner,4 +jovian,5 +kitashinagawa station,1 +radhi jaidi,1 +cordelia scaife may,2 +minor earth major sky,1 +bunty lawless stakes,1 +high capacity color barcode,3 +lyla lerrol,1 +crawford roberts,1 +collin balester,1 +ugo crousillat,1 +om prakash chautala,3 +izzy hoyland,1 +the poet,2 +daryl sabara,6 +aromatic acid,2 +reina sofia,1 +swierczek masovian voivodeship,1 +housing segregation in the united states,2 +karen maser,1 +scaptia beyonceae,2 +kitakyushu city,1 +htc desire 610,4 +dostoevsky,3 +portal victorian era,1 +bose–einstein correlations,3 +ralph hodgson,1 +racquet club,2 +walter camp man of the year,1 +australian movies,1 +k04he,1 +australia–india relations,2 +john william howard thompson,1 +pro cathedral,1 +paddyfield pipit,2 +book finance,1 +ford maverick,10 +slurve,4 +mnozil brass,2 +fiesta 9 1/8 inch square luncheon plate sunflower,1 +korsi,1 +draft 140th operations group,2 +camp,29 +series acceleration,1 +aljouf,1 +democratic party of new mexico,2 +united kingdom general election debates 2010,2 +madura strait,2 +back examination,1 +borgata,2 +il ritorno di tobia,3 +ovaphagous,1 +motörhead,9 +hellmaster,1 +richard keynes,1 +cryogenic treatment,3 +monte porzio,1 +transliteration of arabic,1 +anti catholic,2 +a very merry pooh year,2 +suffixes in hebrew,3 +barr body,16 +alaska constitution,1 +juan garrido,1 +yi lijun,1 +wawa inc,2 +endre kelemen,1 +l brands,18 +lr44,1 +coat of arms of the nagorno karabakh republic,1 +antonino fernandez,1 +salisbury roller girls,1 +zayat,2 +ian meadows,2 +semigalia,1 +khloe and lamar,2 +holding,1 +larchmont edgewater,1 +dynamic parcel distribution,6 +seaworld,30 +assistant secretary of war,1 +digital currency,14 +mazomanie wisconsin,1 +sujatha rangarajan,8 +street child,1 +anna sheehan,1 +violence jack,2 +santi solari,1 +template talk texas in the civil war,1 +colorss foundation,1 +faucaria,1 +alfred gardyne de chastelain,2 +tramp,1 +cannington ontario,2 +penguinone,1 +cardiac arrest,2 +summan grouper,1 +cyndis list,1 +cbs,2 +salminus brasiliensis,2 +kodiak bear,26 +cinemascore,9 +phragmidium,1 +city of vultures,1 +lawrence g romo,1 +chandni chowk to china,1 +scarp retreat,1 +rosses point,1 +carretera de cádiz,1 +chamunda,8 +battle of stalingrad,1 +who came first,2 +salome,5 +portuguese historical museum,3 +westfield sarasota square,1 +muehrckes nails,3 +kennebec north carolina,1 +american classical league,1 +how do you like them apples,1 +mark halperin,20 +circo,1 +turner classic movies,2 +australian rules football in sweden,1 +household silver,3 +frank baird,1 +escape from east berlin,2 +a village romeo and juliet,1 +wally nesbitt,6 +joseph renzulli,2 +spalding gray,1 +dangaria kandha,1 +pms asterisk,2 +openal,1 +romy haag,1 +mh message handling system,4 +pioneer 4,4 +hmcs stettler,1 +gangsta,10 +major third,4 +joan osbourn,1 +mount columbia,2 +active galactic nucleus,14 +robert clary,8 +eva pracht,1 +ion implantation,5 +rydell poepon,4 +baller blockin,2 +enfield chase railway station,1 +serge aurier,13 +florin vlaicu,1 +van diemens land,9 +krishnapur bagalkot,1 +oleksandr zinchenko,96 +collaborations,2 +hecla,2 +amber marshall,7 +inácio henrique de gouveia,1 +bronze age korea,1 +slc punk,5 +ryan jack,2 +clathrus ruber,6 +angel of death,4 +valentines park,1 +extra pyramidal,1 +kiami davael,1 +oleg i shuplyak,1 +nidum,2 +friendship of salem,2 +bèze,3 +arnold weinstock,1 +able,1 +s d ugamchand,1 +the omega glory,2 +ami james,3 +denmark at the 1968 summer olympics,1 +kill me again,1 +richmond town square,1 +guy domville,1 +jessica simpson,1 +kinship care,1 +brugge railway station,2 +unobtainium,16 +carl johan bernadotte,3 +acacia concinna,5 +epinomis,1 +interlachen country club,1 +compromise tariff,1 +fairchild jk,1 +dog trainer,1 +brian dabul,1 +cai yong,1 +jezebel,7 +augarten porcelain,1 +summerslam 1992,1 +ion andoni goikoetxea,2 +dominican church vienna,1 +iffhs worlds best club coach,2 +uruguayan presidential election 2009,2 +saving the queen,1 +un cadavre,1 +history of the jews in france,4 +wbyg,1 +charles de brosses,2 +human weapon,2 +haunted castle,3 +austin maestro,1 +search for extra terrestrial intelligence,1 +suwon,9 +cost per impression,1 +osney lock,1 +markus eriksson,1 +cultural depictions of tony blair,2 +erich kempka,3 +pornogrind,5 +chekhov,1 +marilinda garcia,2 +hard drive,1 +small arms,9 +exploration of north america,8 +international korfball federation,1 +photographic lens design,4 +k hari prasad,1 +lebanese forces,3 +greece at the 2004 summer olympics,1 +lets trim our hair in accordance with the socialist lifestyle,2 +battle of cassinga,5 +donald and the wheel,1 +vti transmission,1 +gille chlerig earl of mar,1 +heart of atlanta motel inc v united states,6 +oh yeah,3 +carol decker,5 +prajakta shukre,4 +profiling,17 +thukima,1 +the great waldo search,1 +nick vincent,2 +the decision of the appeals jury is final and can only be overruled by a decision of the executive committee 2e,1 +civilization board game,1 +erasmus+,1 +eden phillpotts,1 +unleash the beast,1 +varoujan hakhbandian,1 +fermats last theorem,1 +conan the indomitable,1 +vagrant records,1 +house of villehardouin,1 +zoneyesha ulatha,1 +ashur bel nisheshu,1 +ten wijngaerde,2 +lgi homes,1 +american nietzsche a history of an icon and his ideas,1 +european magpie,3 +pablo soto,1 +terminiello v chicago,1 +vladimir cosma,2 +battle of yunnan burma road,1 +ophirodexia,1 +thudar,1 +northern irish,2 +bohemond of tarente,1 +anita moorjani,5 +serra do gerês,1 +fort horsted,1 +metre gauge,2 +stage show,3 +common flexor sheath of hand,2 +conall corc,1 +array slicing,6 +schüfftan process,1 +anmol malik,3 +out cold,2 +antiknock,2 +moss force,1 +paul medhurst,1 +somonauk illinois,1 +george crum,11 +baby talk,6 +daniel mann,4 +vacuum flask,10 +prostitution in the republic of ireland,5 +butch jones,7 +feminism in ukraine,1 +st marys church kilmore county wexford,1 +sonny emory,1 +satsuma han,1 +elben,1 +the best of the rippingtons,3 +m3p,1 +boat sharing,1 +iisco,1 +hoftoren,1 +cannabis in the united kingdom,6 +template talk germany districts saxony anhalt,1 +jean baptiste dutrou bornier,1 +teylers museum,1 +simons problem,2 +gerardus huysmans,1 +pupillary distance,5 +jane lowe,1 +palais de justice brussels,1 +hillsdale free will baptist college,1 +raf wattisham,2 +parnataara,1 +jensen beach campus of the florida institute of technology,1 +scottish gypsy and traveller groups,3 +cliffs shaft mine museum,3 +roaring forties,4 +where in time is carmen sandiego?,2 +perfect field,1 +rob schamberger,1 +lcd soundsystem,10 +alan rathbone,26 +setup,1 +gliding over all,4 +dastur,1 +flensburger brauerei,3 +berkeley global campus at richmond bay,1 +kanakapura,1 +mineworkers union of namibia,1 +tokneneng,3 +mapuche textiles,3 +peranakan beaded slippers,1 +goodra,2 +kanab ut,1 +the gold act 1968,4 +grey langur,1 +procol harum,5 +chris alexander,1 +ft walton beach metropolitan area,3 +dimensionless quantity,16 +the science of mind,1 +alfons schone,1 +euparthenos nubilis,1 +batrachotoxin,5 +fabric live 22,1 +mchenry boatwright,1 +langney sports club,1 +akela jones,1 +lookout,2 +matsuo tsurayaba,2 +general jackson,3 +hair removal,14 +african party for the independence of cape verde,4 +replica trick,1 +bromfenac,2 +make someone happy,1 +sam pancake,1 +denys finch hatton,10 +latin rhythm albums,1 +main bronchus,1 +campidoglio,4 +cathaoirleach,1 +emress justina,1 +sulzbach hesse,1 +noncicatricial alopecia,1 +sylvan place,4 +stalag i c,1 +league of extraordinary gentlemen,1 +sergey korolyov,2 +serbian presidential election 1997,1 +barnes lake millers lake michigan,1 +christmas island health centre,1 +dayton ballet,2 +gilles fauconnier,1 +harald svergja,1 +joanna newsom discography,2 +astro xi yue hd,1 +code sharing,3 +dreamcast vmu,1 +armand emmanuel du plessis duc de richelieu,1 +ecole supérieure des arts du cirque,2 +gerry mulligan,12 +kaaka kaaka,1 +mexico at the 2012 summer olympics,4 +bar wizards,2 +christmas is almost here again,2 +sterling heights michigan,4 +gaultheria procumbens,3 +eben etzebeth,8 +viktorija Čmilytė,1 +los angeles county california,39 +family entertainment,2 +quantum well,9 +elton,1 +allan frewin jones,1 +daniela ruah,32 +gkd legend,1 +coffman–graham algorithm,1 +santa clara durango,1 +brian protheroe,3 +crawler transporter,10 +lakshman,3 +fes el bali,2 +mary a krupsak,1 +irish rugby football union,5 +neuropsychiatry,2 +josé pirela,1 +bonaire status referendum 2015,1 +it,2 +playhouse in the park,1 +alexander yakovlev,7 +old bear,1 +graph tool,2 +merseyside west,1 +romanian armies in the battle of stalingrad,1 +dark they were and golden eyed,1 +aidan obrien,8 +town and davis,1 +suum cuique,3 +german american day,2 +northampton county pennsylvania,3 +candidates of the south australian state election 2010,1 +venator marginatus,2 +k60an,1 +template talk campaignbox seven years war european,1 +maravi,1 +flaithbertach ua néill,1 +junction ohio,1 +dave walter,1 +london transport board,1 +tuyuka,1 +the moodys,3 +noel,3 +eugen richter,1 +cowanshannock township armstrong county pennsylvania,1 +pre columbian gold museum,1 +lac demosson,1 +lincosamides,9 +the vegas connection,1 +stephen e harris,1 +alkali feldspar,2 +brant hansen,1 +draft carnatic music stub,4 +the chemicals between us,1 +blood and bravery,1 +san diego flash,3 +covert channel,5 +ernest w adams,1 +hills brothers coffee,1 +cosmic background explorer,4 +international union of pure and applied physics,2 +vladimir kramnik,21 +hinterland,2 +tinker bell and the legend of the neverbeast,5 +ophisops jerdonii,1 +fine gold,1 +net explosive quantity,3 +miss colorado teen usa,3 +royal philharmonic orchestra discography,1 +elyazid maddour,1 +matthew kelly,2 +templating language,1 +japan campaign,2 +barack obama on mass surveillance,2 +thomas r donahue,1 +old right,4 +spencer kimball,1 +golden kela awards,1 +blinn college,3 +w k simms,1 +quinto romano,1 +richard mulrooney,1 +mr backup z64,1 +monetization of us in kind food aid,1 +alex chilton,2 +propaganda in the peoples republic of china,4 +jiří skalák,8 +m5 stuart tank,1 +template talk ap defensive players of the year,1 +crisis,2 +azuchi momoyama period,1 +care and maintenance,2 +a$ap mob,3 +near field communication,111 +hips hips hooray,1 +promotional cd,1 +andean hairy armadillo,1 +trigueros del valle,1 +elmwood illinois,1 +cantonment florida,2 +margo t oge,1 +national park service,36 +monongalia county ballpark,3 +bakemonogatari,6 +felicia michaels,1 +institute of oriental studies of the russian academy of sciences,2 +economy of eritrea,2 +vincenzo chiarenza,1 +microelectronics,4 +fresno state bulldogs mens basketball,1 +maotou,1 +blokely,1 +duplicati,3 +goud,2 +niki reiser,1 +edward leonard ellington,1 +jaswant singh of marwar,1 +biharsharif,1 +dynasty /trackback/,1 +machrihanish,4 +jay steinberg,1 +peter luger steak house,3 +palookaville,1 +ferrari grand prix results,2 +bankruptcy discharge,2 +mike mccue,2 +nuestra belleza méxico 2013,2 +alex neal bullen,1 +gus macdonald baron macdonald of tradeston,2 +florida circuit court,1 +haarp,2 +v pudur block,1 +grocer,1 +shmuel hanavi,1 +isaqueena falls,2 +jean moulin university,1 +final fantasy collection,1 +template talk american frontier,1 +chex quest,4 +muslim students association,2 +marco pique,1 +jinja safari,1 +the collection,9 +urban districts of germany,5 +rajiv chilaka,1 +zion,2 +vf 32,1 +united states commission on civil rights,2 +zazam,1 +barnettas,4 +rebecca blasband,1 +lincoln village,1 +film soundtracks,1 +angus t jones,77 +snuppy,3 +w/indexphp,30 +file talk american world war ii senior military officials 1945jpeg,1 +worship leader,1 +ein qiniya,1 +buxton maine,1 +matt dewitt,1 +béla bollobás,3 +earlysville union church,1 +bae/mcdonnell douglas harrier ii gr9,1 +californian condor,2 +progressive enhancement,15 +its not my time,4 +ecw on tnn,2 +ihop,36 +aeronautical chart,1 +clique width,1 +fuengirola,8 +archicebus achilles,2 +comparison of alcopops,1 +carla anderson hills,1 +roanoke county virginia,2 +jaílson alves dos santos,1 +rameses revenge,1 +kaycee stroh,5 +les experts,1 +niels skousen,1 +apollo hoax theories,1 +mercedes w204,2 +enhanced mitigation experience toolkit,15 +bert barnes,1 +serializability,6 +ten plagues of egypt,1 +joe l brown,1 +category talk high importance chicago bears articles,1 +stephen caffrey,3 +european border surveillance system,2 +achytonix,1 +m2 machine gun,1 +gurieli,1 +kunefe,1 +m33 helmet,3 +little carmine,1 +smush,3 +josé horacio gómez,1 +product recall,1 +egger,1 +wisconsin highway 55,1 +harbledown,1 +low copy repeats,1 +curt gentry,1 +united colors of benetton,1 +adiabatic shear band,2 +pea galaxy,1 +where are you now,1 +dils,1 +surprise s1,1 +senate oceans caucus,2 +windsor new hampshire,1 +a hawk and a hacksaw,1 +i love it loud,2 +milbcom,1 +old world vulture,7 +camara v municipal court of city and county of san francisco,1 +ski dubai,1 +st cyprians school,2 +aibo,1 +ticker symbol,2 +hendrik houthakker,1 +shivering,5 +jacob arminius,1 +mowming,1 +panjiva,2 +namco libble rabble,5 +rudolph bing,1 +sindhi cap,2 +logician,1 +ford xa falcon,2 +the sunny side up show,1 +helen adams,2 +kharchin,1 +brittany maynard,13 +kim kyu jong,1 +messier 103,3 +leon boiler,1 +the rapeman,1 +twa flight 3,4 +leading ladies,1 +delta octantis,2 +qatari nationality law,1 +lionel cripps,1 +josé daniel carreño,1 +crypsotidia longicosta,1 +polish falcons,1 +highlands north gauteng,1 +the florida channel,1 +oreste barale,1 +ghazi of iraq,2 +charles grandison finney,4 +ahmet ali,1 +abbeytown,1 +caribou,3 +big two,2 +alien,14 +aslantaş dam,3 +theme of the traitor and the hero,1 +vladimir solovyov,1 +laguna ojo de liebre,1 +clive barton,1 +ebrahim daoud nonoo,1 +richard goodwin keats,2 +back to the who tour 51,1 +entertainmentwise,1 +ja preston,1 +john astin,19 +strict function,1 +cam ranh international airport,2 +gary pearson,1 +sven väth,8 +toad,6 +johnny pace,1 +hunt stockwell,1 +rolando schiavi,1 +claudia grassl,1 +oxford nova scotia,1 +maryland sheep and wool festival,1 +conquest of bread,1 +erevan,1 +comparison of islamic and jewish dietary laws,11 +sheila burnford,1 +estevan payan,1 +ocean butterflies international,7 +the royal winnipeg rifles,1 +green goblin in other media,2 +video gaming in japan,8 +church of the guanche people,4 +gustav hartlaub,2 +ian mcgeechan,4 +hammer and sickle,17 +konkiep river,1 +ceri richards,1 +decentralized,2 +depth psychology,3 +centennial parkway,1 +yugoslav monitor vardar,1 +battle of bobbili,2 +magnus iii of sweden,1 +england c national football team,2 +thuraakunu,1 +bab el ehr,1 +koi,1 +cully wilson,1 +money laundering,1 +stirling western australia,1 +jennifer dinoia,1 +eureka street,1 +message / call my name,1 +make in maharashtra,4 +huckleberry creek patrol cabin,1 +almost famous,5 +truck nuts,4 +vocus communications,1 +gikwik,1 +battle of bataan,4 +confluence pennsylvania,2 +islander 23,1 +mv skorpios ii,1 +single wire earth return,1 +politics of odisha,1 +crédit du nord,3 +piper methysticum,2 +coble,2 +kathleen a mattea,1 +coachella valley music and arts festival,50 +tooniverse,1 +spofforth castle,1 +arabian knight,2 +two airlines policy,1 +hinduja group,17 +swagg alabama,1 +portuguese profanity,1 +loomis gang,2 +nina veselova,2 +aegyrcitherium,1 +bees in paradise,1 +béládys anomaly,3 +badalte rishtey,1 +first bank fc,1 +cystoseira,1 +red book of endangered languages,1 +rose,6 +terry mcgurrin,3 +jason hawke,1 +peter chernin,1 +tu 204,1 +the man who walked alone,1 +tool grade steel,1 +wrist spin,1 +one step forward two steps back,1 +theodor boveri,1 +heunginjimun,1 +fama–french three factor model,34 +billy whitehurst,1 +rip it up,4 +red lorry yellow lorry,4 +nao tōyama,8 +general macarthur,1 +rabi oscillation,2 +devín,1 +olympus e 420,1 +hydra entertainment,1 +chris cheney,3 +rio all suite hotel and casino,3 +the death gate cycle,2 +fatima,1 +kamomioya shrine,1 +five nights at freddys 3,14 +the broom of the system,3 +robert blincoe,1 +history of wells fargo,9 +pinocytosis,4 +leaf phoenix,1 +wxmw,2 +tommy henriksen,13 +geri halliwell discography,2 +blade runneri have seen things you would not believe,1 +madhwa brahmins,1 +i/o ventures,1 +edorisi master ekhosuehi,2 +junior orange bowl,1 +khit,2 +sue jones,1 +immortalized,35 +city building series,4 +quran translation,1 +united states consulate,1 +dose response relationship,1 +caitriona,1 +colocolo,21 +medea class destroyer,1 +vaastav,1 +etc1,1 +john altoon,2 +thylacine,113 +cycling at the 1924 summer olympics,1 +margaret nagle,1 +superpower,57 +gülşen,1 +anthems to the welkin at dusk,4 +yerevan united fc,1 +the family fang,14 +domain,4 +high speed rail in india,14 +trifolium pratense,7 +florida mountains,2 +national city corp,5 +length of us participation in major wars,2 +acacia acanthoclada,1 +offas dyke path,2 +enduro,7 +howard center,1 +littlebits,4 +plácido domingo jr,1 +hookdale illinois,1 +the love language,1 +cupids arrows,1 +dc talk,7 +maesopsis eminii,1 +here comes goodbye,1 +freddie foreman,5 +marvel comics publishers,1 +consolidated city–county,5 +countess marianne bernadotte of wisborg,1 +los angeles baptist high school,1 +maglalatik,1 +deo,2 +meilichiu,1 +wade coleman,1 +monster soul,2 +julion alvarez,2 +platinum 166,1 +shark week,12 +hossbach memorandum,4 +jack c massey,3 +ardore,1 +philosopher king,5 +dynamic random access memory,5 +bronze age in southeastern europe,1 +tamil films of 2012,1 +nathalie cely,1 +italian capital,1 +optic tract,3 +shakti kumar,1 +who killed bruce lee,1 +parlement of brittany,3 +san juan national historic site,2 +livewell,2 +template talk om,1 +al bell,2 +pzl w 3 sokół,8 +durrës rail station,3 +david stubbs,1 +pharmacon,3 +railfan,7 +comics by country,2 +cullen baker,1 +maximum subarray problem,19 +outlaws and angels,1 +paradise falls,2 +mathias pogba,28 +donella meadows,4 +john leconte,2 +swaziland national football team,7 +gabriele detti,2 +if ever youre in my arms again,1 +christian basso,1 +helen shapiro,7 +taisha abelar,1 +fluid dynamics,1 +ernest wilberforce,1 +kocaeli university,2 +british m class submarine,1 +modern woodmen of america,1 +las posadas,3 +federal budget of germany,2 +liberation front of chad,1 +sandomierz,5 +ap italian language and culture,1 +manuel gonzález,1 +georgian military road,2 +clear creek county colorado,1 +matt clark,2 +test tube,18 +ak 47,1 +diège,1 +london school of economics+,1 +michael york,14 +half eagle,6 +strike force,1 +type 054 frigate,2 +sino indian relations,7 +fern,3 +louvencourt,1 +ghb receptor,2 +chondrolaryngoplasty,2 +andrew lewer,1 +ross king,1 +colpix records,1 +october 28,1 +tatsunori hara,1 +rossana lópez león,1 +haskell texas,3 +tower subway,2 +waspstrumental,1 +template talk nba anniversary teams,1 +george leo leech,1 +still nothing moves you,1 +blood cancer,3 +buffy lynne williams,1 +dpgc u know what im throwin up,1 +daniel nadler,1 +khalifa sankaré,2 +homo genus,1 +garðar thór cortes,3 +veyyil,1 +matt dodge,1 +hipponix subrufus,1 +anostraca,1 +hartshill park,1 +purple acid phosphatases,1 +austromyrtus dulcis,1 +shamirpet lake,1 +favila of asturias,2 +acute gastroenteritis,1 +dalton cache pleasant camp border crossing,1 +urobilinogen,13 +ss kawartha park,1 +professional chess association,1 +species extinction,1 +gapa hele bi sata,1 +phyllis lyon and del martin,1 +uk–us extradition treaty of 2003,1 +a woman killed with kindness,1 +how bizarre,1 +norm augustine,1 +geil,1 +volleyball at the 2015 southeast asian games,2 +jim ottaviani,1 +chekmagushevskiy district,1 +information search process,2 +queer,63 +william pidgeon,1 +amelia adamo,1 +nato ouvrage "g",1 +tamsin beaumont,1 +economy of syria,13 +douglas dc 8 20,1 +tama and friends,4 +pringles,22 +kannada grammar,7 +lotoja,1 +peony,1 +bmmi,1 +eurovision song contest 1992,11 +cerro blanco metro station,1 +sherlock the riddle of the crown jewels,4 +dorsa cato,1 +nkg2d,8 +specific heat,6 +nokia 6310i,2 +tergum,2 +bahai temple,1 +dal segno,5 +leigh chapman,2 +tupolev tu 144,60 +flight of ideas,1 +rita montaner,1 +vivien a schmidt,1 +battle of the treasury islands,2 +three kinds of evil destination,1 +richlite,1 +medinilla,2 +timeline of aids,1 +colin renfrew baron renfrew of kaimsthorn,2 +hélène rollès,1 +pedro winter,1 +sabine free state,1 +brzeg,1 +palisades park,1 +gas gangrene,11 +dotyk,2 +daniela kix,1 +canna,16 +property list,9 +john hamburg,1 +dunk island,5 +albreda,1 +scammed yankees,1 +wireball,3 +junior 4,1 +absolutely anything,15 +linux operating system,1 +solsbury hill,15 +notopholia,1 +scottish heraldry,2 +template talk paper data storage media,1 +category talk religion in ancient sparta,1 +category talk cancer deaths in puerto rico,1 +mid michigan community college,2 +tvb anniversary awards,1 +frederick taylor gates,1 +omoiyari yosan,3 +journal of the physical society of japan,1 +kings in the corner,2 +nungua,1 +amerika,4 +pacific marine environmental laboratory,1 +the thought exchange,1 +italian bee,5 +roma in spain,1 +sirinart,1 +crandon wisconsin,1 +shubnikov–de haas effect,6 +portrait of maria portinari,4 +colin mcmanus,1 +universal personal telecommunications,1 +royal docks,4 +brecon and radnorshire,3 +eilema caledonica,1 +chalon sur saône,8 +toyota grand hiace,1 +sophorose,1 +semirefined 2bwax,1 +mechanics institute chess club,1 +the culture high,2 +dont wake me up,1 +transcaucasian mole vole,1 +harry zvi tabor,1 +vhs assault rifle,1 +playing possum,2 +omar minaya,2 +private university,1 +yuki togashi,3 +ski free,2 +say no more,1 +diving at the 1999 summer universiade,1 +armando sosa peña,1 +timur tekkal,1 +jura elektroapparate,1 +pornographic magazine,1 +tukur yusuf buratai,1 +keep on moving,1 +laboulbeniomycetes,1 +chiropractor solve problems,1 +mark s allen,3 +committees of the european parliament,4 +blondie,7 +veblungsnes,1 +bank vault,10 +smiling irish eyes,1 +robert kalina,2 +polarization ellipse,2 +huntingdon priory,1 +energy in the united kingdom,34 +hamble,1 +raja sikander zaman,1 +perigea hippia,1 +college of liberal arts and sciences,1 +bootblock,1 +nato reporting names,2 +the serpentwar saga,1 +reformed churches in the netherlands,1 +collaborative document review,4 +combat mission beyond overlord,3 +vlra,2 +pat st john,1 +oceanid,5 +itapetinga,1 +insane championship wrestling,9 +nathaniel gorham,1 +estadio metropolitano de fútbol de lara,2 +william of saint amour,2 +new york drama critics circle award,1 +alliant rq 6 outrider,2 +ilsan,1 +top model po russki,1 +woolens,1 +rutledge minnesota,1 +joigny coach crash,2 +zhou enlai the last perfect revolutionary,1 +the theoretical minimum,1 +arrow security,1 +john shelton wilder,2 +jasdf,2 +katie may,2 +american jewish military history project,1 +business professionals of america,1 +questioned document examination,5 +motorola a760,1 +american steel & wire,1 +louis armstrong at the crescendo vol 1,1 +edward vernon,3 +maria taipaleenmäki,1 +margical history tour,2 +jar jar,1 +australian oxford dictionary,2 +revenue service,2 +odoardo farnese hereditary prince of parma,1 +weekend in new england,1 +laurence harbor new jersey,2 +aramark tower,1 +stealers wheel,1 +cephalon,1 +dawnguard,1 +saintsbury,2 +saint fuscien,1 +ryoko kuninaka,1 +farm to market road 1535,1 +alan kennedy,2 +esteban casagolda,1 +shin angyo onshi,1 +william gowland,1 +eastern religions,6 +kenny lala,1 +alphonso davies,1 +tadamasa hayashi,1 +meet the parents,2 +calvinist church,1 +ristorante paradiso,1 +jose joaquim champalimaud,1 +olis,1 +mill hill school,2 +lockroy,1 +battle of princeton,10 +cent,8 +brough superior ss80,1 +ras al khaima club,3 +washington international university,3 +bradley kasal,2 +miguel Ángel varvello,1 +oxygen permeability,1 +femoral circumflex artery,1 +golden sun dark dawn,4 +pusarla sindhu,1 +toyota winglet,1 +wind profiler,1 +montefiore medical center,2 +template talk guitar hero series,3 +little leaf linden,1 +ramana,4 +islam in the czech republic,2 +manuel vitorino,1 +joseph radetzky von radetz,3 +francois damiens,1 +parasite fighter,1 +friday night at st andrews,3 +hurbazum,1 +haidhausen,1 +petabox,2 +salmonella enteritidis,2 +matthew r denver,1 +de la salle,1 +anti terrorism act 2015,6 +brugsen,1 +mountain times,1 +columbia basin project,1 +common wallaroo,2 +clepsis brunneograpta,1 +red hot + dance,1 +mao fumei,1 +dark shrew,1 +coach,8 +come saturday morning,1 +aanmai thavarael,1 +hellenia,1 +donate life america,2 +plot of beauty and the beast toronto musical,1 +births in 1243,3 +main page/wiki/portal technology,8 +cambridgeshire archives and local studies,1 +big pines california,1 +pegasus in popular culture,4 +baron glendonbrook,1 +your face sounds familiar,5 +boom tube,2 +richard gough,8 +the new beginning in niigata,3 +american academy of health physics,1 +plain,9 +tushino airfield,1 +king george v coronation medal,1 +geologic overpressure,1 +seille,1 +calorimeter,25 +french civil service,1 +david l paterson,1 +chinese gunboat chung shan,2 +rhizobium inoculants,1 +wizard,4 +baghestan,1 +paustian house,2 +ellen pompeo,55 +damien williams,1 +tomoe tamiyasu,1 +acute epithelial keratitis,1 +casey abrams,8 +mendozite,1 +kantian ethics,2 +mcclure syndicate,1 +tokyo metro,6 +cuisine of guinea bissau,1 +mossberg 500,18 +mollie gillen,1 +above and beyond party,1 +joey carbone,1 +faulkner state community college,1 +tetsuya ishikawa,1 +electric flag,3 +meet the feebles,2 +kplm,1 +when we were twenty one,1 +horus bird,2 +youth in revolt,8 +spongebob squarepants revenge of the flying dutchman,3 +ehow,5 +nikos xydakis,2 +ziprasidone,19 +ulsan airport,1 +flechtingen,1 +dave christian,3 +delaware national guard,1 +skaria thomas,1 +iraca,1 +kkhi,2 +swimming at the 2015 world aquatics championships – mens 1500 metre freestyle,2 +crossing lines,37 +john du cane,1 +i8,1 +bauer pottery,1 +affinity sutton,4 +lotus 119,1 +uss arleigh burke,1 +palmar interossei,2 +nofx discography,4 +bwia west indies airways,3 +gopala ii,1 +north fork correctional facility,1 +szeged 2011,1 +milligram per cent,2 +halas and batchelor,1 +what the day owes the night,1 +sighișoara medieval festival,5 +scarning railway station,1 +cambridge hospital,1 +amnesia labyrinth,2 +cokie roberts,7 +savings identity,3 +pravia,1 +mcgrath,4 +pakistan boy scouts association,1 +dan carpenter,2 +marikina–infanta highway,2 +genetic analysis,2 +template talk ohio state university,1 +thomas chamberlain,4 +moe book,1 +coyote waits,1 +black protestant,1 +neetu singh,19 +mahmoud sarsak,1 +casa loma,28 +bedivere,8 +boundary park,2 +danger danger,14 +jennifer coolidge,49 +pop ya collar,1 +collaboration with the axis powers during world war ii,10 +greenskeepers,1 +the dukes children,1 +alaska off road warriors,1 +twenty five satang coin,1 +template talk private equity investors,2 +american red cross,24 +jason shepherd,1 +georgetown college,2 +ocean countess,1 +ammonium magnesium phosphate,1 +community supported agriculture,5 +philosophy of suicide,4 +yard ramp,2 +captain germany,1 +bob klapisch,1 +i will never let you down,2 +february 11,6 +ron dennis,13 +rancid,16 +the mall blackburn,1 +south high school,6 +charles allen culberson,1 +organizational behavior,66 +automatic route selection,1 +uss the sullivans,9 +yo no creo en los hombres,1 +janet,1 +serena armstrong jones viscountess linley,3 +louisiana–lafayette ragin cajuns mens basketball,1 +flower films,1 +michelle ellsworth,1 +norbertine rite,2 +spanish mump,1 +shah jahan,67 +fraser coast region,1 +matt cornwell,1 +nra,1 +crested butte mountain resort,1 +college football playoff national championship,2 +craig heaney,4 +devil weed,1 +satsuki sho,1 +jordaan brown,1 +little annie,4 +thiha htet aung,1 +the disreputable history of frankie landau banks,1 +mickey lewis,1 +eldar nizamutdinov,1 +m1825 forage cap,1 +antonina makarova,1 +mopani district municipality,2 +al jahra sc,1 +chaim topol,4 +tum saath ho jab apne,1 +piff the magic dragon,7 +imagining argentina,1 +ni 62,1 +phys rev lett,1 +the peoples political party,1 +casoto,1 +popular movement of the revolution,4 +huntingtown maryland,1 +la bohème,33 +khirbat al jawfa,1 +lycksele zoo,1 +deveti krug,2 +cuba at the 2000 summer olympics,2 +rose wilson,7 +sammy lee,2 +dave sheridan,10 +universal records,2 +antiquities trade,3 +shoveller,1 +tapered integration,1 +parker pen company,4 +mushahid hussain syed,1 +nynehead,1 +counter reformation,2 +nhl on nbc,11 +ronny rosenthal,2 +arsenie todiraş,3 +lobster random,1 +halliburton,37 +gordon county georgia,1 +belle isle florida,3 +molly stanton,3 +green crombec,1 +geodesist,2 +abd al rahman al sufi,4 +demography of japan,26 +live xxx tv,5 +naihanchi,1 +cofinite,1 +msnbot,5 +clausard,1 +mimidae,1 +wind direction,15 +irrational winding of a torus,1 +tursiops truncatus,1 +trustee,1 +lumacaftor/ivacaftor,2 +balancing lake,2 +shoe trees,1 +cycling at the 1928 summer olympics – mens team pursuit,1 +calponia harrisonfordi,1 +hindu rate of growth,1 +dee gordon,7 +passion white flag,2 +frog skin,1 +rudolf eucken,2 +bayantal govisümber,1 +christopher a iannella,1 +robert myers,1 +james simons,1 +meng xuenong,1 +abayomi olonisakin,1 +milton wynants,1 +cincinnatus powell,1 +atomic bomb band,1 +hopfield network,12 +jet pocket top must,1 +the state of the world,1 +welf i duke of bavaria,2 +american civil liberties union v national security agency,3 +elizabeth fedde,1 +librarything,2 +kim fletcher,1 +tracy island,2 +praise song for the day,1 +superstar,7 +ewen spencer,1 +back striped weasel,1 +cs concordia chiajna,1 +bruce curry,1 +malificent,1 +dr b r ambedkar university,2 +river plate,1 +desha county arkansas,1 +harare declaration,2 +patrick dehornoy,1 +paul alan cox,2 +auckland mounted rifles regiment,1 +mikoyan gurevich dis,3 +corn exchange manchester,2 +sharpshooter,1 +the new york times manga best sellers of 2013,1 +max perutz,2 +andrei makolov,1 +inazuma eleven saikyō gundan Ōga shūrai,2 +tatra 816,1 +ashwin sanghi,8 +pipestone township michigan,1 +craig shoemaker,1 +david bateson,1 +lew lehr,1 +crewe to manchester line,2 +samurai champloo,36 +tali ploskov,2 +janet sobel,3 +kabe station,1 +rippon,1 +alexander iii equestrian,1 +louban,2 +the twelfth night,1 +delaware state forest,1 +the amazing race china 3,1 +brillouins theorem,1 +extreme north,3 +super frelon,1 +george watsons,1 +mungo park,1 +workin together,3 +boy,12 +brownsville toros,1 +kim lim,1 +futsal,63 +motoring taxation in the united kingdom,1 +accelerator physics codes,1 +arytenoid cartilage,3 +the price of beauty,3 +life on the murder scene,2 +hydrophysa psyllalis,1 +jürgen brandt,2 +economic history association,2 +the sandwich girl,1 +heber macmahon,1 +volume 1 sound magic,2 +san francisco–oakland–hayward ca metropolitan statistical area,9 +harriet green,7 +tarnawa kolonia,1 +eur1 movement certificate,20 +anna nolan,2 +gulf of gökova,1 +havertown,2 +orlando scandrick,4 +doug owston correctional centre,1 +asterionella,4 +espostoa,1 +ranked voting system,10 +commercial law,39 +kirk,1 +mongolian cuisine,8 +turfanosuchus,1 +arthur anderson,4 +sven olof lindholm,1 +batherton,1 +dimetrodon,1 +pianos become the teeth,1 +united kingdom in the eurovision song contest 1976,1 +medieval,11 +it bites,1 +ion television,8 +seaboard system railroad,3 +sayan mountains,3 +musaffah,1 +charles de foucauld,3 +urgh a music war,1 +translit,1 +american revolutionary war/article from the 1911 encyclopedia part 1,1 +uss mauna kea,1 +powder burn,1 +bald faced hornet,9 +producer of the year,1 +the most wanted man,1 +clear history,8 +mikael lilius,1 +class invariant,4 +forever michael,3 +goofing off,3 +tower viewer,3 +claudiu marin,1 +nicolas cage,1 +waol,2 +s10 nbc respirator,2 +education outreach,1 +gyeongsan,2 +template talk saints2008draftpicks,1 +botaurus,1 +francis harper,1 +mauritanian general election 1971,1 +kirsty roper,2 +non steroidal anti inflammatory drug,17 +nearchus of elea,2 +resistance to antiviral drugs,1 +raghavendra rajkumar,5 +template talk cc sa/sandbox,1 +washington gubernatorial election 2012,2 +paul lovens,1 +express freighters australia,2 +bunny bleu,2 +osaka prefecture,2 +federal reserve bank of boston,4 +hacı ahmet,1 +underground chapter 1,10 +filippo simeoni,2 +the wonderful wizard of oz,3 +sailing away,1 +avelino gomez memorial award,1 +badger,65 +hongkou football stadium,3 +benjamin f cheatham,2 +fair isaac,2 +kwab,1 +al hank aaron award,3 +gender in dutch grammar,1 +idiom neutral,2 +da lata,1 +tuu languages,1 +derivations are used,1 +clete patterson,1 +danish folklore,4 +android app //orgwikipedia/http/enmwikipediaorg/wiki/westfield academy,1 +toto,8 +ea,1 +victory bond tour,1 +credai,2 +hérin,1 +st james louisiana,1 +necrolestes,2 +cable knit,1 +saunderstown,1 +us route 52 in ohio,1 +sailors rest tennessee,1 +adlai stevenson i,6 +miscibility,13 +help footnotes,13 +murrell belanger,1 +new holland pennsylvania,5 +haldanodon,1 +feminine psychology,2 +riot city wrestling,1 +mobile content management system,2 +zinio,1 +central differencing scheme,2 +enoch,2 +usp florence admax,1 +maester aemon,7 +norman "lechero" st john,1 +ice racing,1 +tiger cub economies,6 +klaipėda region,12 +wu qian,8 +malayalam films of 1987,1 +estadio nuevo la victoria,1 +nanotoxicology,2 +hot revolver,1 +nives ivankovic,1 +glen edward rogers,5 +epicene,3 +eochaid ailtlethan,1 +judiciary of finland,1 +en jersey,1 +statc,1 +atta kim,1 +mizi research,2 +acs applied materials & interfaces,1 +thank god youre here,9 +loneliness,8 +h e b plus,2 +corella bohol,1 +money in the bank,59 +golden circle air t bird,1 +flash forward,1 +category talk philippine television series by network,1 +dfmda,1 +the road to wellville,8 +ernst tüscher,1 +commission,14 +abdul rahman bin faisal,6 +oversea chinese banking corporation,7 +ray malavasi,1 +al qadisiyah fc,4 +anisfield wolf book award,1 +jacques van rees,1 +jakki tha motamouth,1 +scoop,1 +piti,2 +carlos reyes,1 +v o chidambaram pillai,6 +diamonds sparkle,1 +the great transformation,5 +cardston alberta temple,1 +la vendetta,1 +miyota nagano,1 +national shrine of st elizabeth ann seton,2 +chaotic,1 +breastfeeding and hiv,1 +friedemann schulz von thun,1 +mukhammas,2 +fishbowl worldwide media,1 +mohamed amin,3 +john densmore,10 +suryadevara nayaks,1 +metal gear solid peace walker,12 +ché café,2 +old growth,1 +lake view cemetery,1 +konigsberg class cruiser,1 +courts of law,1 +nova scotia peninsula,3 +jairam ramesh,4 +portal kerala/introduction,1 +edinburgh 50 000 – the final push,1 +ludachristmas,3 +motion blur,1 +deliberative process privilege,2 +bubblegram,1 +simon breach grenade,2 +tess henley,1 +gojinjo daiko,1 +common support aircraft,2 +zelda rubinstein,9 +yolanda kakabadse,1 +american studio woodturning movement,1 +richard carpenter,67 +vehicle door,3 +transmission system operator,9 +christa campbell,9 +marolles en brie,1 +korsholma castle,1 +murder of annie le,3 +kims,1 +zionist union,8 +portal current events/june 2004,2 +marination,8 +cap haïtien international airport,2 +fujima kansai,1 +vampire weekend discography,3 +moncton coliseum,2 +wing chair,1 +el laco,2 +castle fraser,1 +template talk greek political parties,1 +society finch,1 +chief executive officer,4 +battle of bloody run,3 +coat of arms of tunisia,2 +nishi kawaguchi station,1 +colonoscopy,30 +vic tayback,5 +lonnie mack discography,3 +yusuf salman yusuf,2 +marco simone,4 +saint just,1 +elizabeth taylor filmography,6 +haglöfs,2 +yunis al astal,1 +daymond john,36 +bedd y cawr hillfort,1 +durjoy datta,1 +wealtheow,1 +aaron mceneff,1 +culture in berlin,1 +temple of saturn,6 +nermin zolotić,1 +the darwin awards,1 +patricio pérez,1 +chris levine,1 +misanthropic,1 +dragster,2 +eldar,19 +chrzanowo gmina szelków,1 +zimmerberg base tunnel,6 +jakob schaffner,1 +california gubernatorial recall election 2003,1 +tommy moe,1 +bikrami calendar,1 +mama said,11 +hellenic armed forces,8 +candy box,3 +monstervision,3 +kachin independent army,1 +pro choice,1 +tshiluba language,1 +trucial states,9 +collana,1 +best music video short form,1 +pokémon +giratina+and+the+sky+warrior,1 +etteldorf,1 +academic grading in chile,2 +land and liberty,3 +australian bureau of meteorology,1 +cheoin gu,1 +william henry green,1 +ewsd,2 +gate of hell,1 +sioux falls regional airport,3 +nevelj zsenit,1 +bevo lebourveau,1 +ranjana ami ar asbona,1 +shaun fleming,1 +jean antoine siméon fort,1 +sports book,1 +vedran smailović,3 +simple harmonic motion,29 +wikipedia talk wikiproject film/archive 16,1 +princess jasmine,13 +great bustard,5 +allred unit,1 +cheng san,1 +mini paceman,1 +flavoprotein,2 +storage wars canada,3 +university rowing,2 +category talk wikiproject saskatchewan communities,1 +the washington sun,1 +rotary dial,6 +hailar district,1 +assistant secretary of the air force,2 +the décoration for the yellow house,5 +chris mclennan,1 +the cincinnati kid,4 +education in the republic of ireland,15 +steve brodie,2 +country club of detroit,1 +wazner,1 +portal spain,4 +senna,3 +william j bernd house,1 +balaji baji rao,8 +worth dying for,1 +cool ruler,1 +turn your lights down low,2 +mavroudis bougaidis,1 +national registry emergency medical technician,1 +james young,8 +eyewire,1 +dark matters twisted but true/,1 +josé pascual monzo,1 +german election 1928,2 +linton vassell,1 +convention on the participation of foreigners in public life at local level,1 +thorium fuel cycle,5 +honeybaby honeybaby,1 +golestan palace,3 +lombok international airport,11 +mainichi daily news,1 +k&p,1 +liberal network for latin america,1 +cádiz memorial,1 +grupo corripio,1 +elie and earlsferry,1 +isidore geoffroy saint hilaire,1 +al salmiya sc,2 +piano sonata hob xvi/33,1 +e f bleiler,1 +national register of historic places listings in york county virginia,3 +gupta empire,2 +german immigration to the united states,1 +through gates of splendor,2 +iap,1 +love takes wing,1 +tours de merle,1 +aleksey zelensky,1 +paul almond,2 +boston cambridge quincy ma nh metropolitan statistical area,1 +komiks presents dragonna,1 +princess victoire of france,1 +alan pownall,3 +tilak nagar,2 +lg life sciences co ltd,8 +before their eyes,1 +labor right,5 +michiko to hatchin,1 +susan p graber,1 +xii,1 +hanswulf,1 +symbol rate,17 +myo18b,2 +rowing at the 2010 asian games – mens coxed eight,1 +caspar weinberger jr,2 +bettle juice,1 +battle of the morannon,7 +darlington county south carolina,1 +mayfield pennsylvania,1 +ruwerrupt de mad,1 +luthfi assyaukanie,1 +fiat panda,30 +wickiup reservoir,1 +tanabe–sugano diagram,6 +alexander sacher masoch prize,1 +intracellular transport,1 +church of the val de grâce,1 +jebel ad dair,1 +rosalind e krauss,6 +cross origin resource sharing,97 +readiness to sacrifice,1 +creel terrazas family,1 +phase portrait,9 +subepithelial connective tissue graft,1 +lake malawi,18 +phillips & drew,1 +ernst vom rath,2 +infinitus,1 +geneva convention for the amelioration of the condition of the wounded and sick in armies in the field,2 +world heritage,1 +dole whip,8 +leveling effect,1 +bioship,3 +vanilloids,2 +superionic conductor,1 +basil bernstein,7 +armin b cremers,2 +szlichtyngowa,1 +beixinqiao station,1 +united states presidential election in utah 1980,1 +watson v united states,3 +willie mcgill,1 +melle belgium,1 +al majmaah,1 +mesolimbic dopamine pathway,1 +six flags new england,5 +acp,2 +geostrategy,2 +original folk blues,1 +wentworth military academy,1 +bromodichloromethane,3 +doublet,4 +tawfiq al rabiah,1 +sergej jakirović,1 +mako surgical corp,3 +empire of lies,1 +old southwest,1 +bay of arguin,1 +bringing up buddy,1 +mustapha hadji,7 +raymond kopa,7 +evil horde,1 +kettering england,1 +extravaganza,1 +christian labour party,2 +joice mujuru,6 +v,15 +le père,4 +my fathers dragon,2 +cumulus cloud,32 +fantasy on themes from mozarts figaro and don giovanni,1 +postpone indefinitely,1 +extreme point,1 +iraq–israel relations,1 +henry le scrope 3rd baron scrope of masham,1 +rating beer,1 +claude alvin villee jr,2 +clackamas town center,2 +roope latvala,4 +richard bethell 1st baron westbury,1 +ryan gosling,1 +yelina salas,1 +amicus,1 +cecilia bowes lyon countess of strathmore and kinghorne,6 +programming style,9 +now and then,9 +somethingawful,1 +nuka hiva campaign,1 +bostongurka,2 +jorge luis ochoa vázquez,1 +philip burton,1 +rainbow fish,7 +road kill,5 +christiane frenette,2 +as if,1 +paul ricard,1 +roberto dañino,1 +shoyu,1 +jakarta,96 +dean keith simonton,1 +mastocytosis,19 +hiroko yakushimaru,3 +problem of other minds,2 +jaunutis,1 +tfp deficiency,1 +access atlantech edutainment,1 +kristian thulesen dahl,1 +william wei,1 +andy san dimas,10 +kempten/allgäu,1 +augustus caesar,9 +conrad janis,1 +tugaya lanao del sur,1 +second generation antipsychotics,1 +anema e core,2 +sucking the 70s,1 +the czars,2 +vakulabharanam,1 +f double sharp,3 +prymnesin,1 +dick bavetta,2 +billy jones,3 +columbine,4 +file talk joseph bidenjpg,1 +mandelbrot set,79 +constant elasticity of variance model,2 +morris method,1 +al shamal stadium,5 +hes alright,1 +madurai massacre,1 +philip kwon,2 +christadelphians,7 +this man is dangerous,2 +kiowa creek community church,1 +pier paolo vergerio,1 +order of the most holy annunciation,2 +john plender,1 +vallée de joux,2 +graysby,1 +ludwig minkus,3 +potato aphid,1 +bánh bột chiên,1 +wilhelmstraße,1 +fee waybill,1 +designed to sell,1 +ironfall invasion,2 +lieutenant governor of the isle of man,1 +third reading,2 +eleanor roosevelt high school,1 +su zhe,1 +heat conductivity,1 +si satchanalai national park,1 +etale space,1 +faq,24 +low carbohydrate diet,1 +differentiation of integrals,1 +karl fogel,2 +tom chapman,3 +james gamble rogers,2 +jeff rector,1 +burkut,9 +joe robinson,1 +turtle flambeau flowage,1 +moves like jagger,3 +turbaco,1 +oghuz turk,2 +latent human error,5 +square number,17 +rugby football league championship third division,2 +altoona pennsylvania,23 +circus tent,1 +satirical novel,1 +claoxylon,1 +barbaros class frigate,4 +oyer and terminer,2 +telephone numbers in the bahamas,1 +thomas c krajeski,2 +mv glenachulish,1 +sports broadcasting contracts in australia,3 +car audio,1 +ted lewis,2 +eric bogosian/robotstxt,2 +furman university japanese garden,1 +jed clampett,2 +flintstone,2 +c of tranquility,2 +rutali,2 +berkhamsted place,1 +wissam ben yedder,13 +nt5e,1 +erol onaran,1 +allium amplectens,1 +the three musketeers,2 +north eastern alberta junior b hockey league,1 +doggie daddy,1 +lauma,1 +the love racket,1 +eta hoffman,1 +ryans four,3 +omerta – city of gangsters,1 +humberview secondary school,2 +parels,1 +the descent,1 +evgenia linetskaya,1 +manhunt international 1994,1 +american society of animal science,1 +american samoa national rugby union team,1 +faster faster,1 +all creatures great and small,1 +mama said knock you out,9 +rozhdestveno memorial estate,2 +wizard of odd,1 +lugalbanda,4 +beardsley minnesota,1 +the rogue prince,10 +uss escambia,1 +stormy weather,3 +couleurs sur paris,1 +madrigal,4 +colin tibbett,1 +lemelson–mit prize,2 +phonetical singing,1 +glucophage,3 +suetonius,10 +ungra,1 +black and white minstrel,1 +woolwich west by election 1975,1 +trolleybuses in wellington,2 +jason macdonald,3 +ussr state prize,2 +robert m anderson,1 +kichijōji,1 +apache kid wilderness,1 +sneaky pete,8 +edward knight,1 +fabiano santacroce,1 +hemendra kumar ray,1 +sweat therapy,1 +stewart onan,2 +israel–turkey relations,1 +natalie krill,5 +clinoporus biporosus,1 +kosmos 2470,2 +vladislav sendecki,1 +healthcare in madagascar,1 +template talk 2010 european ryder cup team,1 +richard lyons,1 +transfer of undertakings regs 2006,3 +image processor,3 +alvin wyckoff,1 +kōbō abe,1 +kettle valley rail trail,1 +my baby just cares for me,3 +u28,1 +western australia police,10 +scincidae,1 +partitionism,1 +glenmorangie distillery tour,1 +river cave,1 +szilárd tóth,1 +i dont want nobody to give me nothing,1 +city,67 +annabel dover,2 +placebo discography,8 +showbiz,8 +solio ranch,1 +loan,191 +morgan james,10 +international federation of film critics,3 +the frankenstones,2 +pastor bonus,1 +billy purvis,1 +the gunfighters,1 +sandefjord,2 +ohio wine,2 +for the love of a man,1 +drifters,10 +ilhéus,1 +bikini frankenstein,1 +subterranean homesick alien,1 +chemical nomenclature,17 +great wicomico river,1 +ingrid caven,1 +japanese destroyer takanami,1 +nosler partition,1 +wagaman northern territory,1 +slovak presidential election 2019,1 +fuggerei,12 +al hibah,1 +irish war of independence,2 +joan smallwood,1 +anthony j celebrezze jr,1 +mercedes benz m130 engine,2 +phineas and ferb,2 +belgium womens national football team,3 +reynevan,1 +joe,1 +alan wilson,1 +epha3,1 +belarus national handball team,1 +phaedra,14 +move,2 +amateur rocketry,3 +epizootic hemorrhagic disease,5 +prague derby,4 +basilica of st thérèse lisieux,1 +pompeianus,1 +solved game,3 +tramacet,19 +essar energy,3 +lumbar stenosis,1 +part,24 +hải vân tunnel,1 +vsm group,3 +walter hooper,2 +consumer needs,1 +bell helicopter,18 +launde abbey,2 +ramune,10 +declarations of war during world war ii,1 +saint laurent de la salanque,1 +balkenbrij,1 +balgheim,1 +out of the box,13 +cappella,1 +national pharmaceutical pricing authority,4 +friend and foe,1 +new democracy,1 +eastern phoebe,2 +isipum of geumgwan gaya,1 +tel quel,1 +traveler,12 +superbeast,1 +oddsac,1 +zamora spain,1 +declaration of state sovereignty of the russian soviet federative socialist republic,1 +chumash painted cave state historic park california,3 +zentiva,1 +british rail class 88,5 +west indies cricket board,3 +pauli jørgensen,1 +punisher kills the marvel universe,7 +william de percy,1 +vehicle production group,4 +uc irvine anteaters mens volleyball,2 +dong sik yoon,1 +hyæna,2 +canadian industries limited,1 +mr ii,1 +jim muhwezi,1 +citizen jane,2 +night and day concert,1 +double precision floating point format,2 +herbal liqueurs,1 +the fixed period,5 +pip/taz,1 +lesser caucasus,2 +uragasmanhandiya,2 +alternative words for british,2 +khuzaima qutbuddin,1 +helmut balderis,2 +wesley r edens,1 +scott sassa,4 +mutant mudds,3 +east krotz springs louisiana,1 +leonard frey,3 +counting sort,15 +leandro gonzález pírez,2 +shula marks,1 +sierville,1 +california commission on teacher credentialing,1 +raymond loewy,10 +beevor foundry,1 +dog snapper,2 +hitman contracts,5 +eduard herzog,1 +wittard nemesis of ragnarok,1 +cape may light,1 +al saunders,3 +distant earth,2 +beam of light,2 +arent we all?,1 +veridicality,1 +private enterprise,3 +rambhadracharya,3 +dps,5 +beckdorf,1 +rúaidhrí de valera,1 +vivian bang,3 +sugar pine,1 +vn parameswaran pillai,1 +henry ross perot sr,1 +the arcadian,1 +the record,6 +g turner howard iii,1 +oleksandr usyk,12 +mumbai suburban district,5 +vicente dutra,1 +paean,1 +scottish piping society of london,1 +ingot,11 +alex obrien,6 +autonomous counties of china,1 +kaleorid,1 +remix & repent,3 +gender performativity,7 +godheadsilo,1 +tonsilloliths,1 +la dawri,1 +kiran more,3 +billboard music award for woman of the year,1 +tahitian ukulele,1 +buick lacrosse,14 +draft helen milner jury sent home for the night,2 +history of japanese cuisine,6 +time tunnel,1 +albert odyssey 2,1 +oysters rockefeller,4 +jim mahon,1 +evolutionary invasion analysis,1 +sunk cost fallacy,3 +universidad de manila,1 +morgan crucible,1 +southern miss golden eagles football,2 +horatio alger,13 +biological psychopathology,1 +hollywood,115 +product manager,21 +thomas burgh 3rd baron burgh,1 +stan hack,1 +peloponesian war,1 +republic of china presidential election 2004,2 +sanitarium,4 +growthgate,1 +samuel e anderson,1 +bobo faulkner,1 +kaffebrenneriet,1 +monponsett pond seaplane base,1 +powers of horror,3 +viburnum burkwoodii,1 +new suez canal,5 +gerardo ortíz,2 +japhia life,1 +paul pastur,1 +fuller craft museum,1 +nomal valley,1 +inaugural address,1 +saint Étienne du vigan,1 +lip ribbon microphone,2 +mary cheney,2 +piebald,6 +kadambas,1 +transportation in omaha,7 +before the league,1 +feltham and heston by election 2011,1 +aboriginal music of canada,3 +dnssec,6 +sshtunnels,1 +robin benway,1 +swimming at the 1968 summer olympics – mens 4 x 200 metre freestyle relay,1 +commission internationale permanente pour lepreuve des armes à feu portatives,3 +death rock,1 +hugo junkers,6 +gmt,3 +keanu reeves,2 +beverly kansas,1 +charlotte blair parker,1 +kids,5 +weight bench,1 +kiasmos,8 +basque country autonomous basketball team,1 +gideon toury,2 +gugak/,1 +texass 32nd congressional district,2 +have you ever been lonely,1 +take the weather with you,1 +chukchi,1 +the magicians wife,1 +juan manuel bordeu,1 +port gaverne,1 +music for films iii,1 +northern edo masquerades,1 +hang gliding,15 +marine corps logistics base barstow,2 +century iii mall,1 +peter tarlow,1 +thermal hall effect,1 +david ogden stiers,18 +webmonkey,1 +five cereals,2 +osceola washington,1 +clover virginia,2 +sphinginae,2 +stuart brace,1 +al di meola discography,7 +sunflowers,1 +hasty generalization,4 +polish athletic association,1 +the purge 3,2 +bitetti combat mma 4,1 +hiroko nagata,2 +mona seilitz,1 +mixed member proportional representation,7 +rancho temecula,2 +sinai,1 +norrmalmstorg robbery,5 +silesian walls,1 +floyd stahl,1 +gary becker,1 +knowledge engineering,5 +port of mobile,1 +luckiest girl alive,2 +ilya rabinovich,1 +bridge,3 +el general,3 +cornerstone schools,1 +gozmo,1 +charles courtney curran,1 +broker,32 +us senate committee on banking housing and urban affairs,2 +retroversion of the sovereignty to the people,1 +giorgi baramidze,1 +lars grael,1 +abdul qadir,3 +pgrep,2 +category talk seasons in danish womens football,1 +malus sieversii,1 +god squad,4 +category of acts,1 +melkote,1 +linda langston,1 +sherry romanado,1 +montana sky,8 +history of burkina faso,1 +iso 639 kxu,1 +los angeles fire department museum and memorial,1 +recognize,1 +der bewegte mann,6 +davy pröpper,1 +outline of vehicles,2 +gesta francorum,1 +sidney w pink,1 +ronald pierce,1 +martin munkácsi,1 +nord noreg,1 +accounting rate of return,7 +urwerk,1 +albert gallo,1 +antennaria dioica,3 +transport in sudan,2 +fladry,1 +cumayeri,1 +bennington college,11 +pêro de alenquer,2 +sixth man,1 +william i of aquitaine,1 +radisson diamond,1 +belgian united nations command,1 +venus genetrix,1 +sayesha saigal,14 +inverse dynamics,2 +national constitutional assembly,1 +honey bear,4 +certosa di pavia,2 +selective breeding,31 +let your conscience be your guide,1 +han hyun jun,1 +closed loop,8 +template talk golf major championships master,1 +twin oaks community virginia,1 +red flag,3 +housing authority of new orleans,2 +joice heth,4 +toñito,1 +ivan pavlov,2 +madanapalle,4 +ptat,1 +renger van der zande,1 +anaerobic metabolism,2 +patrick osullivan,1 +shirakoya okuma,1 +permian high school,9 +thomas h ford,1 +southfield high school,1 +religion in kuwait,2 +nathrop colorado,1 +hefner hugh m,1 +whitney bashor,1 +pope shenouda iii of alexandria,7 +thomas henderson,1 +tokka and rahzar,13 +windows thumbnail cache,3 +consumer council for water,1 +sake bombs and happy endings,1 +lothlórien,1 +the space bar,4 +sakuma rail park,1 +oas albay,3 +dan frankel,1 +cliff hillegass,1 +iron sky,12 +pentile matrix family,1 +oregon system,1 +california sea lion,7 +jeanneau,2 +meadowhall interchange,1 +lille catholic university,1 +nuñomoral,1 +vending machine,30 +xarelto,1 +jonbenét ramsey,3 +progresso castelmaggiore,1 +tacticity,6 +wing arms,1 +gag,2 +hank greenberg,8 +garda síochána,14 +puggy,1 +p sainath,1 +the year of living dangerously,9 +army reserve components overseas training ribbon,1 +hmas nestor,1 +john beckwith,1 +florida constitution,2 +yonne,3 +benoît richaud,1 +mamilla pool,2 +gerald bull,14 +david halberstam,12 +my fair son,2 +ncaa division iii womens golf championships,1 +anniela,1 +king county,1 +kamil jankovský,1 +synaptic,3 +rab,6 +switched mode regulator,1 +history of biochemistry,1 +halaf,2 +henry colley,1 +co postcode area,3 +social finance uk,1 +cercospora,2 +the dao,1 +unité radicale,2 +shinji hashimoto,3 +tommy remengesau,3 +isobel gowdie,2 +mys prasad,9 +national palace museum of korea,1 +basílica del salvador,2 +no stone unturned,2 +walton group,1 +foramen ovale,1 +slavic neopaganism,1 +iowa county wisconsin,3 +melodi grand prix junior,1 +jarndyce and jarndyce,3 +talagunda,1 +nicholas of autrecourt,1 +substitution box,3 +the power of the daleks,1 +real gas,6 +edward w hincks,1 +kangxi dictionary,5 +natural world,1 +h h asquith,21 +francis steegmuller,1 +sasha roiz,3 +media manipulation,1 +looking for comedy in the muslim world,2 +bytown,4 +previsualization,1 +rita ora discography,11 +kiersey oklahoma,1 +henry greville 3rd earl of warwick,1 +draft,4 +phenolate,1 +i believe,1 +virologist,1 +relief in abstract,1 +eastern medical college,1 +purveyance,2 +ascending to infinity,2 +sportstime ohio,2 +church of wells,1 +ivory joe hunter,1 +wayne mcgregor,2 +luna 17,4 +viscount portman,2 +wikipedia talk wikipedia signpost/2009 07 27/technology report,1 +negramaro,1 +barking owl,2 +i need you,2 +brockway mountain drive,1 +template talk albatros aircraft,1 +future shock,11 +china national highway 317,1 +laurent gbagbo,7 +plum pudding model,18 +league of the rural people of finland,1 +dundees rising,1 +nikon f55,1 +olympic deaths,5 +gemma jones,19 +hafsa bint al hajj al rukuniyya,1 +personal child health record,1 +logic in computer science,11 +bhyve,3 +hothouse,1 +log house,6 +library of celsus,2 +the lizzie bennet diaries,1 +leave this town the b sides ep,1 +estimated time of arrival,8 +chariotry in ancient egypt,2 +american precision museum,1 +dimos moutsis,1 +scriptlet,1 +something in the wind,1 +sharka blue,1 +time on the cross the economics of american negro slavery,1 +tomislav kiš,1 +khalid islambouli,7 +bankruptcy abuse prevention and consumer protection act,7 +gračanica bosnia and herzegovina,2 +jungs theory of neurosis,5 +mgm animation,1 +soviet support for iran during the iran–iraq war,3 +native american,1 +template talk nigeria squad 1994 fifa world cup,1 +norwegian lutheran church,4 +adia barnes,1 +coatings,1 +mehdi hajizadeh,1 +the dead matter cemetery gates,1 +fuzzy little creatures,1 +waje,7 +anji,1 +heinz haber,1 +turkish albums chart,1 +sebastian steinberg,1 +price fixing cases,2 +bellator 48,1 +edgar r champlin,1 +otto hermann leopold heckmann,1 +bishops stortford fc,4 +stern–volmer relationship,6 +morgan quitno,2 +five star general,1 +iso 13406 2,1 +black prince,11 +leopard kung fu,1 +felix wong,5 +mary claire king,6 +alvar lidell,1 +playonline,1 +infantry branch,1 +andrew pattison,1 +john turmel,1 +kent,74 +edwin palmer hoyt,1 +captivity narratives,1 +jaguar xj220,1 +hms tanatside,2 +new faces,2 +edward levy lawson 1st baron burnham,1 +samuel woodfill,3 +jewish partisans,9 +abandonware,16 +early islamic philosophy,2 +sleeper cell,5 +media of africa,2 +san andreas,3 +luxuria,2 +egon hostovský,3 +pelagibacteraceae,1 +martin william currie,1 +borescope,21 +narratives of islamic origins the beginnings of islamic historical writing,1 +lecompton constitution,2 +axé bahia,2 +paul goodman,1 +template talk washington nationals roster navbox,1 +a saucerful of secrets,2 +david carol macdonnell mather,1 +portal buddhism,3 +florestópolis,1 +alecs+golf+ab,1 +bank alfalah,1 +frank pellegrino,3 +loutre,1 +erp4it,2 +monument to joe louis,2 +witch trial of nogaredo,1 +sabrina santiago,2 +no night so long,3 +helena carter,1 +renya mutaguchi,3 +yo yogi,4 +bolivarian alliance for the americas,3 +cooper boone,1 +uss iowa,24 +mitsuo iso,2 +cranberry,1 +batrachotomus,1 +richard lester,5 +bermudo pérez de traba,1 +rosser reeves ruby,1 +telecommunications in morocco,4 +i a richards,1 +nidhal guessoum,1 +lilliefors test,6 +the silenced,5 +mambilla plateau,1 +sociology of health and illness,3 +tereza chlebovská,2 +bismoll,3 +kim suna,1 +scream of the demon lover,1 +joan van ark,7 +intended nationally determined contributions,6 +dietary supplement,16 +last chance mining museum,1 +savoia marchetti s65,1 +if i can dream,1 +maharet and mekare,4 +nea anchialos national airport,2 +american journal of digestive diseases,1 +chance,2 +lockheed f 94c starfire,1 +the game game,1 +kuzey güney,3 +semmering base tunnel,1 +three mile island,1 +evaluation function,1 +robert mckee,4 +carmelo soria,1 +moneta nova,1 +pīnyīn,1 +international submarine band,3 +elections in the bahamas,5 +powell alabama,1 +kmgv,1 +charles stuart duke of kendal,2 +echo and narcissus,7 +trencrom hill,1 +ashwini dutt,1 +the herzegovina museum,1 +liverpool fc–manchester united fc rivalry,12 +kerber,1 +flakpanzer 38,8 +demographics of bihar,2 +rico reeds,1 +vandenberg afb space launch complex 3,1 +wiesendangen,1 +lamm,1 +allen doyle,2 +anusree,5 +broad spectrum,1 +bay middleton,2 +connect savannah,1 +history of immigration to canada,22 +waco fm,3 +nakano takeko,1 +murnau am staffelsee,2 +minarchy,1 +haymans dwarf epauletted fruit bat,1 +brachyglottis repanda,1 +associative,1 +mississippi aerial river transit,1 +stefano siragusa,2 +gregor the overlander,3 +marine raider,1 +pogorzans,1 +sportcity,2 +garancahua creek,1 +vincent dimartino,3 +ninja,2 +natural history museum of bern,1 +revolutionary catalonia,4 +chiayi,1 +alix strachey,3 +looe island,1 +college football usa 96,1 +off peak return,1 +minsk 1 airport,1 +evangelical lutheran church in burma,2 +riemann–roch theorem,1 +the comic strip,2 +vladimir istomin,1 +america again,2 +brown treecreeper,1 +american high school,1 +powerglide,2 +oolitic limestone,1 +daz1,1 +jarrow vikings,1 +pierre philippe thomire,1 +dorothy cadman,1 +gaston palewski,3 +twin river bridges,1 +im yours,1 +ambrose dudley 3rd earl of warwick,3 +ssim,2 +original hits,1 +cosmonaut,9 +special educational needs and disability act 2001,4 +will you speak this word,1 +history of wolverhampton wanderers fc,1 +don lawrence,1 +tokyo metropolitan museum of photography,1 +orduspor,1 +john lukacs,3 +patrice collazo,1 +lords resistance army insurgency,5 +ronald "slim" williams,5 +drivin for linemen 200,1 +nicolò da ponte,1 +bucky pope,1 +ewing miles brown,2 +ugly kid joe,28 +american flight 11,1 +louzouer,1 +district hospital agra,1 +jessica jane applegate,1 +sexuality educators,1 +serie a scandal of 2006,1 +at war with reality,1 +stephen wiltshire,13 +vechigen switzerland,1 +rikki clarke,3 +rayakottai,1 +permanent magnet electric motor,1 +qazi imdadul haq,1 +plywood,49 +ntr telugu desam party,1 +skin lightening,1 +royal natal national park,1 +uss mcdougal,2 +queen of the sun,1 +karanjachromene,1 +on 90,1 +enrique márquez,1 +siegfried and roy,1 +city manager,6 +wrdg,1 +why i am not a christian,3 +protein coding region,1 +royal bank of queensland gympie,1 +british invasions of the river plate,2 +yasufumi nakanoue,1 +magnetic man,1 +kickback,3 +tillandsia subg allardtia,1 +north american nr 349,1 +edict of amboise,1 +st andrew square edinburgh,2 +flag of washington,2 +timeless,2 +new york state route 125,3 +fudge,3 +single entry bookkeeping system,5 +refractive surgery,8 +bi monthly,1 +park high school stanmore,1 +norton anthology of english literature,1 +michael wines,1 +gaff rig,1 +kosmos 1793,1 +major facilitator superfamily,2 +talpur dynasty,1 +byron bradfute,1 +quercitello,1 +rcmp national protective security program,1 +ann kobayashi,1 +recurring saturday night live characters and sketches,3 +abraham hill,1 +nagapattinam district,4 +pidgeon,3 +mycalessos,1 +technical university of hamburg,1 +electric shock&ei=ahp0tbk0emvo gbe v2bbw&sa=x&oi=translate&ct=result&resnum=2&ved=0ceaq7gewaq&prev=/search?q=electric+shock&hl=da&biw=1024&bih=618&prmd=ivns,2 +aim 54 phoenix,18 +undercut,5 +gokhale memorial girls college,1 +digital penetration,19 +centre for peace studies tromsø,1 +richie williams,1 +walloon region,1 +albany city hall,2 +maxine carr,4 +anglosphere,18 +effect of world war i on children in the united states,1 +josh bell,1 +german thaya,1 +brian murphy,3 +marguerite countess of blessington,1 +leak,1 +bubble point,5 +international federation of human rights,1 +clubcorp,2 +greater philadelphia,1 +daniel albright,1 +macas,1 +roses,4 +woleu ntem,1 +shades of blue,1 +say aah,2 +curtiss sbc,1 +ion andone,1 +firstborn,1 +marringarr language,2 +ann e todd,1 +native american day,4 +stand my ground,1 +bavington,1 +classification of indigenous peoples of the americas,2 +always,6 +leola south dakota,1 +psycilicibin,2 +roy rogers,1 +marmalade,1 +national prize of the gdr,1 +shilp guru,1 +m2 e 50,1 +jorge majfud,2 +cutter and bone,1 +william steeves,1 +lisa swerling,2 +grace quigley,5 +telecommunications in yemen,1 +rarotonga international airport,7 +cycling at the 2010 central american and caribbean games,2 +mazda b3000,1 +hanwencun,1 +adurfrazgird,1 +ivan ivanov vano,1 +yhwh,1 +qarshi,4 +oshibori,2 +uppada,1 +iain clough,1 +painted desert,7 +tugzip,1 +my little pony fighting is magic,143 +pantheon,2 +chinese people in zambia,1 +yves saint laurent,3 +texas helicopter m79t jet wasp ii,1 +forever reign,1 +charlotte crosby,32 +ealdormen,9 +copper phosphate,2 +mean absolute difference,5 +hôtel de soubise,5 +josh rees,2 +non commissioned officer,70 +gb jones,1 +im feeling you,2 +book of shadows,9 +brain trauma,1 +sulpitius verulanus,1 +vikranth,5 +space adaptation syndrome,6 +united states presidential election in hawaii 1988,1 +joe garner,4 +river suir bridge,2 +the beach boys medley,1 +joyce castle,1 +christophe wargnier,1 +ik people,2 +sketch show,1 +buena vista police department,1 +file talk layzie bone clevelandjpg,1 +gillian osullivan,3 +prince albert of saxe coburg and gotha,2 +berean academy,1 +motorcraft quality parts 500,1 +frederick law olmsted,21 +born this way,9 +sterling virginia,4 +if wishes were horses beggars would ride,1 +section mark,1 +tapi,1 +navy cross,1 +housekeeper,1 +gian battista marino,1 +planá,1 +chiromantes haematocheir,1 +colonial life & accident insurance company,4 +aduana building,2 +kim johnston ulrich,1 +berkelium 254,1 +m&t bank corp,2 +sit up,1 +sheknows,1 +phantom lady,1 +bruce kamsinky,1 +commercial drive,1 +chinese people in the netherlands,1 +sylvia young theatre school,4 +influenza a virus subtype h2n3,1 +dracut,2 +nate webster,1 +vila velebita,1 +uaz patriot,4 +democratic unification party,1 +alexander slidell mackenzie,1 +portland mulino airport,1 +first person shooter,2 +the temporary widow,1 +terry austin,1 +the foremans treachery,1 +hms blenheim,1 +sodium dichloro s triazinetrione,1 +kurt becher,1 +cumberland gap tn,1 +newton cotes,1 +daphne guinness,6 +internal tide,1 +god and gender in hinduism,2 +howlin for you,1 +stellarator,14 +cavea,3 +faye ginsburg,1 +lady cop,3 +template talk yugoslavia squad 1986 fiba world championship,1 +solidarity economy,1 +second presidency of carlos andrés pérez,1 +bora bora,71 +xfs,1 +christina bonde,1 +agriculture in australia,20 +scenic drive,1 +richard mantell,1 +motordrome,1 +broadview hawks,1 +misty,2 +international bank of commerce,2 +istanbul sapphire,5 +changkat keruing,1 +the hotel inspector unseen,1 +tharwa australian capital territory,2 +strauss,2 +shock film,1 +ulick burke 1st marquess of clanricarde,2 +valencia cathedral,5 +kay bojesen,1 +palogneux,1 +texas beltway 8,1 +jackie walorski,7 +capital punishment in montana,1 +byte pair encoding,2 +upper deerfield township new jersey,2 +lucca comics & games,1 +lee chae young,1 +czar alexander ii,1 +kool ad,6 +leopold van limburg stirum,1 +john dunn,1 +policeman,2 +what dreams may come,3 +grant ginder,1 +chieverfueil,2 +long island express,1 +malmö sweden,2 +song for my father,1 +see saw,2 +jean jacques françois le barbier,5 +do rag,11 +dsb bank,2 +davical,6 +cervical cap,1 +gershon yankelewitz,1 +the last hurrah,4 +category talk educational institutions established in 1906,1 +tour pleyel,1 +león klimovsky,1 +phyoe phyoe aung,1 +phil sawyer,2 +android app //orgwikipedia/http/enmwikipediaorg/wiki/swiftkey,1 +deontological,3 +juan dixon,12 +robert pine,4 +alexander tilloch galt,2 +common tailorbird,12 +derailed,7 +mike campbell,3 +terminator 2 3 d battle across time,3 +technische universität münchen,4 +baloana,1 +echis leucogaster,1 +lahore pigeon,1 +william de beauchamp 9th earl of warwick,2 +erin go bragh,14 +economics u$a,1 +villafranca montes de oca,1 +pope eusebius,2 +martin kruskal,1 +félix de blochausen,1 +jeff jacoby,1 +mark krein,2 +travis wester,2 +fort louis de la louisiane,1 +weddingwire,2 +ping,54 +don swayze,8 +steve hamilton,3 +rhenish,1 +winrar,3 +births in 1561,4 +copyright law of the netherlands,2 +floodland,9 +tamil nadu tourism development corporation,1 +dolls house,1 +chkrootkit,1 +search for the hero,1 +avenal,1 +tini,2 +patamona,1 +aspendos international opera and ballet festival,2 +felix cora jr,5 +yellow cardinal,2 +antony jay,1 +conda,1 +a tramp shining,1 +william miller,1 +holomictic lake,2 +growler,2 +the violence of summer,1 +meerschaum,3 +cd138,1 +karl friedrich may,1 +history of iraq,2 +henry ford,139 +rumwold,1 +beatrice di tenda,1 +blaze,1 +nick corfield,1 +walt longmire,5 +eleazar maccabeus,1 +business edition,1 +karl oyston,4 +gypsy beats and balkan bangers,1 +fa premier league 2004 05,1 +agawan radar bomb scoring site,1 +the hall of the dead,1 +combat training centre,1 +moroccan portuguese conflicts,2 +pokipsy,1 +minor characters in csi crime scene investigation,1 +miguel molina,1 +buckypaper,2 +magazine,4 +forget about it,2 +marco schällibaum,1 +r d smith,1 +nfl playoff results,2 +four score,1 +centenary bank,2 +london borough of camden,12 +bhumij,1 +counter reformation/trackback/,1 +billy volek,1 +cover song,1 +awang bay,1 +douglas fitzgerald dowd,3 +architecture of ancient greece,5 +ny1,2 +academy award for best visual effects,3 +history of the mbta,2 +triangle group,1 +charles r fenwick,1 +berenice i of egypt,1 +window detector,1 +corruption perception index,1 +leffrinckoucke,1 +lee anna clark,1 +burndy,2 +inset day,2 +american association of motor vehicle administrators,1 +ckm matrix,1 +angiopoietin 1,1 +steven marsh,1 +open reading frame,27 +telesystems,1 +pastoral poetry,1 +west wycombe park,2 +lithium,7 +nogales international airport,1 +wajków,1 +sls 1,1 +trillo,2 +max s,1 +verndale,1 +yes sir i can boogie,1 +blog spam,10 +daniel veyt,1 +william brown,3 +takami yoshimoto,1 +josh greenberg,4 +geoffrey heyworth 1st baron heyworth,1 +medeina,3 +anja steinlechner,1 +riviera beach florida,2 +gerris wilkinson,1 +north american lutheran church,1 +paul dillett,11 +proto euphratean language,1 +best selling books,2 +pumpellyite,1 +business objects,1 +fodor,2 +xanadu,3 +london river,1 +draft juan de orduña,2 +barriemore barlow,3 +jew harp,1 +birmingham,1 +titus davis,1 +march 2012 gaza–israel clashes,1 +energy demand management,2 +aquarium of the americas,3 +tto,1 +l h c tippett,1 +optical fiber,88 +onești,2 +stanley ntagali,1 +prussian blue,1 +bill kovach,2 +hip pointer,3 +alessandra amoroso,4 +fleet racing,1 +navy maryland rivalry,1 +cornering force,1 +the mighty quest for epic loot,5 +katalyst,2 +the beef seeds,1 +shack out on 101,1 +aircraft carrier operations,1 +overseas province,2 +institute of state and law,1 +light truck,5 +plastics in the construction industry,2 +little zizou,2 +congenic,2 +adriaen van utrecht,1 +brian mcgrath,3 +parvati,1 +jason gwynne,1 +kphp,1 +miryusif mirbabayev,1 +kōriyama castle,3 +the making of a legend gone with the wind,2 +shot traps,1 +awa tag team championship,1 +littlebourne,2 +franchot tone,4 +john dudley 2nd earl of warwick,2 +mass spec,1 +final fantasy vi,44 +gerry ellis,1 +adon olam,3 +man 24310,1 +p n okeke ojiudu,1 +unqi,1 +snom,1 +bruce bagemihl,1 +category talk animals described in 1932,1 +metalist oblast sports complex,1 +colley harman scotland,1 +suka,1 +anita sarkeesian,81 +kazakhstan national under 17 football team,1 +ym,2 +matt barnes,1 +tour phare,1 +bellus–claisen rearrangement,2 +turkey at the 2012 summer olympics,1 +irréversible,32 +umbilical nonseverance,1 +wood stave,1 +indian pentecostal church of god,1 +camponotus nearcticus,3 +john tesh,13 +syncline,4 +skins,50 +kelsey manitoba,1 +alkayida,2 +polyglotism,17 +forensic statistics,2 +ram vilas sharma,8 +pearl jam,71 +dj max fever,1 +islamic view of miracles,5 +kds,1 +alabama cavefish,1 +johanna drucker,1 +tom wolk,4 +rottenburg,2 +goshen connecticut,2 +maker media,1 +morphett street adelaide,1 +keystone hotel,1 +baseball hall of fame balloting 2005,1 +gongzhuling south railway station,1 +ss charles bulfinch,1 +sig mkmo,1 +cartman finds love,2 +embassy of syria in washington dc,1 +charles prince of wales,175 +teachings of the prophet joseph smith,1 +charles iv,1 +alethea steven,1 +type i rifle,2 +a peter bailey,1 +brain cancer,1 +eric l clay,2 +jett bandy,1 +moro rebellion,9 +eustachów,1 +avianca el salvador,2 +dont stop the party,4 +reciprocal function,1 +dagmar damková,1 +hautmont,1 +penguin english dictionary,2 +waddie mitchell,1 +technician fourth grade,3 +hot girls in love,1 +critérium du dauphiné,59 +love song,2 +roger ii,2 +whitbread book award,1 +thomas colepeper 2nd baron colepeper,2 +a king and no king,1 +big fish & begonia,5 +mayville new york,2 +molecularity,1 +ed romero,1 +one watt initiative,3 +jeremy hellickson,2 +william morgan,1 +giammario piscitella,1 +eastern lesser bamboo lemur,1 +padre abad district,1 +don brodie,1 +facts on the ground,1 +undeniable evolution and the science of creation,1 +john of giscala,1 +bryce harper,45 +gabriela irimia,1 +empire earth mobile,1 +the queen vic,1 +helen rowland,1 +mixed nuts,5 +malacosteus niger,2 +george r r martin/a song of ice and fire,1 +brock osweiler,11 +tough,1 +outline of agriculture,4 +sea wolf,1 +mo vaughn,4 +the brood of erys,1 +composite unit training exercise,1 +isabella acres,4 +the jersey,5 +coal creek bridge,1 +habana libre,1 +nicole pulliam,1 +john shortland,1 +daniel pollen,1 +magic kit,1 +baruch adonai l&,1 +a daughters a daughter,2 +laughlin nevada,11 +tubercule,1 +louis laurie,1 +internet boom,3 +conversion of paul,1 +comparison of software calculators,1 +choctaw freedmen,2 +josh eady,1 +hôpital charles lemoyne,2 +u mobile,2 +john tomlinson,1 +baré esporte clube,2 +tuğçe güder,2 +highams park railway station,4 +newport east,1 +clothing industry,6 +scott rosenberg,6 +my 5 wives,2 +matt godfrey,1 +port ellen,2 +winecoff hotel fire,1 +fide world chess championship 2005,2 +lara piper,1 +the little mermaid,1 +foxmail,6 +penn lyon homes,1 +stockholm opera,1 +american journal of theology,1 +bernard gorcey,3 +rodger collins,1 +clarkeulia sepiaria,1 +korean era name,3 +melide ticino,1 +unknown to no one,1 +asilinae,1 +scânteia train accident,1 +parti de la liberté et de la justice sociale,1 +falkland islands sovereignty dispute,13 +castile,10 +french battleship flandre,1 +nils taube,1 +anisa haghdadi,1 +william tell told again,2 +magister,3 +zgc 7,1 +national agricultural cooperative marketing federation of india,3 +les bingaman,1 +chebfun,1 +portal current events/august 2014,2 +eparchy of oradea mare,1 +tempo and mode in evolution,2 +seili,1 +boniface,3 +supportersvereniging ajax,1 +support team,1 +lactometer,1 +twice as sweet,1 +spruce pine mining district,2 +banknotes of the east african shilling,1 +cerebral cortex,3 +tagalogs,1 +german diaspora,8 +grammelot,1 +max a,1 +category talk vienna culture,1 +cheung kong graduate school of business,1 +three certainties,1 +multani,3 +barry callebaut,15 +joanne mcneil,1 +z grill,4 +commonwealth of australia constitution act 1900,1 +ganzorigiin mandakhnaran,1 +peter h schultz,1 +ea pga tour,3 +scars & memories,1 +exodus from lydda,1 +states reorganisation act 1956,4 +guy brown,1 +horsebridge,1 +arthur mafokate,1 +aldus manutius,5 +american daylight,3 +jean chaufourier,2 +edmond de caillou,1 +hms iron duke,9 +displeased records,1 +quantum turing machine,3 +ncert textbook controversies,2 +dracs,1 +beyrouth governorate,1 +staphylococcus caprae,1 +tankard,2 +surfaid international,1 +hohenthurn,2 +mission x 41,1 +professional wrestling hall of fame,2 +george mountbatten 4th marquess of milford haven,2 +athletics at the 2012 summer paralympics womens club throw f31 32/51,1 +knots and crosses,1 +edge vector,1 +philippe arthuys,1 +baron raglan,1 +odell beckham jr,3 +elfriede geiringer,1 +hyflux,1 +author level metrics,2 +ieee fellow,1 +pori brigade,3 +polyphenol antioxidant,1 +the brothers,8 +kakaji Ōita,1 +shyam srinivasan,2 +shahid kapoor,88 +chuckie williams,1 +colonial,4 +roman spain,1 +convolvulus pluricaulis,1 +william j burns international detective agency,1 +accessibility for ontarians with disabilities act 2005,1 +linguist,1 +agonist,2 +xiaozi,1 +holker hall,1 +novatium,1 +alois jirásek,1 +lesser crested tern,1 +names of european cities in different languages z,1 +hydrogen cooled turbogenerator,2 +indian airlines flight 257,1 +united states attorney for the northern district of indiana,1 +this is us,11 +transaction capabilities application part,1 +culiacán,6 +hash based message authentication code,65 +heinz murach,1 +dual citizen,2 +zhizn’ za tsarya,1 +gabriel taborin technical school foundation inc,1 +deaths in july 1999,1 +aponi vi arizona,1 +amish in the city,2 +goodbye cruel world,1 +st augustine grass,10 +moesi,1 +violette leduc,3 +methyl formate,9 +you walk away,1 +the traveler,1 +bond,89 +moa cuba,3 +hebrew medicine,1 +women in the russian and soviet military,2 +help log,2 +cuillin,5 +back fire,14 +salesrepresentativesbiz,1 +hogsnort rupert,1 +dwarf minke whale,1 +embassy of albania ottawa,1 +cotai water jet,1 +st lucie county florida,8 +wesselman,1 +american indian art,1 +richard arkless,1 +trolleybuses in bergen,1 +vama buzăului,1 +far east movement,9 +threes a crowd,1 +insane,3 +linux technology center,4 +patty duke,24 +smuckers,1 +kapalua,1 +amf futsal world cup,5 +umes chandra college,1 +jnanappana,2 +bar bar bar,1 +beretta m951,2 +libertarian anarchism,1 +fart proudly,4 +peyton place,5 +phase detection autofocus,1 +cavalry in the american civil war,9 +class stratification,1 +battle of cockpit point,1 +regiment van heutsz,2 +ana rivas logan,1 +nenya,1 +westland wah 64 apache,1 +roslyn harbor new york,3 +august wilhelm von hofmann,1 +professional baseball,2 +douglas feith,1 +pogrom,21 +aušra kėdainiai,1 +pseudopeptidoglycan,4 +arquà petrarca,1 +wayampi,1 +conservative government 1866 1868,1 +world naked bike ride,28 +fruitvale oil field,2 +shuttle buran,1 +robert c pruyn,1 +totem,1 +megalotheca,1 +nkechi egbe,1 +james p comeford,1 +heavens memo pad,7 +cauca valley,1 +jungfraujoch railway station,2 +seo in guk,24 +bold for delphi,1 +multiple frames interface,1 +zhenli ye gon,6 +kyabram victoria,1 +two stars for peace solution,1 +couette flow,9 +new formalism,2 +template talk 1930s comedy film stub,1 +template talk scream,1 +joona toivio,4 +iaaf silver label road race,1 +super bowl xxviii,5 +i aint never,1 +paul little racing,1 +jacobite rising of 1715,3 +katherine archuleta,1 +programmable logic device,12 +footsteps of our fathers,2 +once upon a tour,1 +tauck,1 +budapest memorandum on security assurances,5 +prostitution in chad,2 +bebedouro,2 +vice,2 +madredeus,1 +p diddy,1 +princess alice of the united kingdom,20 +jerry hairston jr,1 +neo noir,3 +self evaluation motives,1 +relativity the special and the general theory,2 +the sign of four,3 +kevin deyoung,1 +robin long,1 +mokshaa helsa,1 +nagaon,1 +aniceto esquivel sáenz,1 +sda,2 +german battlecruiser gneisenau,1 +assisted reproductive technology,12 +cmmg,1 +vision of you,1 +keshia chanté discography,1 +biofuel in the united kingdom,1 +katinka ingabogovinanana,1 +hutt valley,1 +garwol dong,1 +tunceli province,3 +edwin bickerstaff,1 +halloween 3 awesomeland,1 +canadian records in track and field,1 +ubisoft são paulo,1 +midstream,16 +jethro tull,4 +childhoods end,55 +ss rohilla,1 +lagranges four square theorem,6 +bucky pizzarelli,3 +jannik bandowski,80 +guðni Ágústsson,1 +multidimensional probability distribution,1 +brno–tuřany airport,2 +broughtonia,5 +cold hands warm heart,1 +simone biles,32 +bf homes parañaque,2 +akaflieg köln ls11,3 +street fighter legacy,2 +beautiful kisses,1 +first modern olympics,1 +macbook air,1 +dublab,1 +silent night deadly night,6 +earth defense force 2025,2 +grant township carroll county iowa,1 +gary williams,1 +malmö aviation,1 +geographical pricing,2 +anaheim memorial medical center,1 +mary+mallon,1 +henry a byroade,1 +wawasan 2020,4 +eurovision dance contest,6 +lydia polgreen,1 +pilsen kansas,1 +colin sampson,1 +neelamegha perumal temple,1 +james bye,2 +canadian federation of agriculture,1 +f w de klerk,34 +bob casey jr,3 +northport east,1 +elian gonzalez affair,1 +aleksei bibik,1 +anthony dias blue,1 +pyaar ke side effects,4 +fusako kitashirakawa,1 +cal robertson,4 +shandong national cultural heritage list,1 +police story 3 super cop,5 +the third ingredient,3 +dean horrix,1 +pico el león,1 +cesar chavez street,1 +prospered,1 +children in cocoa production,5 +gervase helwys,1 +binary digit,1 +kovai sarala,4 +mathematics and music,1 +macroglossum,1 +f gary gray,21 +broadsoft,2 +cachan,4 +bukkake,21 +church of st margaret of scotland,1 +christopher cockerell,3 +amsterdam oud zuid,1 +county of bogong,1 +intel mobile communications,1 +the legend of white fang,1 +millwright,19 +will buckley,1 +bill jelen,2 +template talk san francisco 49ers coach navbox,1 +amalia garcía,1 +because he lives,1 +air charts,1 +stade edmond machtens,1 +henry stommel,1 +dxgi,1 +misr el makasa sc,1 +chad price,2 +carl henning wijkmark,1 +acanthogorgiidae,1 +diqduq,1 +prelog strain,2 +crispin the cross of lead,4 +avraham adan,2 +barbershop arranging,1 +free x tv,1 +eric guillot,1 +kht,1 +never a dull moment,1 +lwów school of mathematics,1 +sears centre,3 +chin state,6 +van halen 2007 2008 tour,1 +robert weinberg,3 +fierté montréal,2 +vince jack,1 +heikki kuula,1 +architecture of the republic of macedonia,1 +glossary of education terms,1 +aleksandra szwed,1 +military history of europe,3 +exeter central railway station,1 +staroselye,1 +lee thomas,7 +saint peters square,2 +romanization of hispania,2 +file talk dodecahedrongif,1 +signed and sealed in blood,8 +colleges of worcester consortium,1 +district electoral divisions,1 +galkot,1 +king África,3 +monetary policy,57 +brp ang pangulo,2 +battle of mạo khê,1 +air tube,1 +ruth ashton taylor,2 +keith jensen,1 +headland alabama,1 +willie loomis,1 +interactive data extraction and analysis,2 +georgetown city hall,2 +chuck es in love,2 +weeksville brooklyn,1 +anatoly sagalevich,2 +browett lindley & co,1 +barnawartha victoria,1 +pop,2 +black balance,2 +aceratorchis,1 +emmeline pethick lawrence baroness pethick lawrence,1 +osso buco,1 +herminie cadolle,2 +telegram & gazette,2 +le van hieu,1 +pine honey,2 +nexvax2,1 +leicester north railway station,1 +jacqueline foster,1 +bill handel,3 +nizami street,1 +radke,1 +bob mulder,1 +ambroise thomas,4 +carles puigdemont i casamajó,1 +callable bond,6 +tesco metro,2 +mohan dharia,1 +great hammerhead,12 +vinko coce,3 +john mayne,1 +cobb cloverleaf,1 +uhlan,10 +giulio migliaccio,1 +belmont university,6 +rinucumab,1 +kearny high school,1 +chūgen,1 +stages,2 +boar%27s head carol,1 +knight of the bath,1 +ayres thrush,7 +sing hallelujah,1 +the tender land,2 +wholesale banking,1 +jean jacques perrey,5 +maxime bossis,2 +sherman records,1 +alan osório da costa silva,1 +fannie willis johnson house,1 +blacks equation,2 +levinthals paradox,2 +thomas scully,2 +necron,3 +university of alberta school of business,5 +lake shetek,1 +toby maduot,1 +gavriil golovkin,1 +sweetwater,3 +atlantic revolutions,2 +jaime reyes (comics,1 +kajang by election 2014,1 +mycotoxigenic,1 +san marco altarpiece,2 +line impedance stabilization network,2 +santiago hernández,1 +jazzland,3 +host–guest chemistry,4 +giovanni florio,2 +st marylebone school,1 +acqua fragile,1 +the horse whisperer,10 +don francis,1 +mike molesevich,1 +brad wright,1 +north melbourne football club,3 +brady dragmire,1 +margaret snowling,2 +wing chun terms,4 +mckey sullivan,1 +derek ford,1 +cache bus,1 +bernie grant arts centre,2 +amata francisca,1 +sinha,2 +larissa loukianenko,1 +oceans apart&sa=u&ved=0ahukewjw4n6eqdblahun7gmkhxxebd8qfgg4mag&usg=afqjcnhhjagrbamjgaxc7rpsso4i9z jgw,1 +anemone heart,2 +alison mcinnes,1 +juan lindo,1 +mahesh bhupati,1 +baháí faith in taiwan,5 +cinema impero,1 +template talk rob thomas,1 +likin,1 +science & faith,1 +fort saint elmo,3 +delhi kumar,6 +juha lallukka,1 +situational sexual behavior,2 +milligan indiana,1 +william em lands,1 +karl anselm duke of urach,2 +hérold goulon,1 +vedic mathematics,20 +move to this,1 +koussan,1 +floored,1 +raghu nandan mandal,1 +angels gods secret agents,1 +orthogonal,2 +the little house on the prairie,1 +chilean pintail,1 +guardian angel,2 +st leonard maryland,1 +green parties in the united kingdom,1 +time to say goodbye,1 +alba michigan,2 +harbourfront centre,1 +corner tube boiler,1 +consensus government,1 +ppru 1,1 +corporate anniversary,4 +sazerac company,5 +kyle friend,1 +bmw k1100lt,1 +pergola marche,1 +commonwealth of kentucky,2 +taiwan passport,2 +clare quilty,1 +domenico caprioli,1 +frank m hull,1 +cheng sui,2 +nazi board games,3 +spark bridge,1 +derrick thomas,6 +wunnumin 1,1 +emotion remixed +,4 +brian howard dix,2 +brigalow queensland,2 +burgi dynasty,1 +apolonia supermercados,1 +brandon lafell,2 +one day,24 +nara period,9 +template talk the land before time,1 +assyrians in iraq,1 +trade union reform and employment rights act 1993,2 +template talk evansville crimson giants seasons,1 +boys be smile / 目覚めた朝にはきみが隣に,2 +kapuloan sundha kecil,1 +human impact of internet use,1 +kolkata metro line 2,3 +saint pardoux morterolles,1 +carfin grotto,2 +samuel johnson prize,3 +french royal family,1 +android app //orgwikipedia/http/enmwikipediaorg/wiki/victoria park,1 +mazda xedos 9,1 +măiestrit,1 +petroleum economist,2 +penetration,2 +adrian rawlins,8 +plutonium 239,11 +culture of montreal,1 +british germans,2 +warszawa wesoła railway station,1 +lorenzo di bonaventura,6 +military ranks of estonia,1 +uss flint,8 +arthur f defranzo,1 +sadeh,1 +jammu and kashmir,3 +igor budan,2 +charmila,2 +choi,1 +mohammed ali khan walajah,1 +sourabh varma,1 +after here through midland,1 +martyn day,1 +justin larouche,1 +illinoiss 6th congressional district,4 +jackson wy,1 +tyson apostol,4 +mitch morse,1 +robert davila,1 +canons regular of saint john cantius,1 +giant girdled lizard,2 +cascade volcanoes,5 +fools day,1 +cordyline indivisa,1 +pueraria,2 +swiss folklore,4 +meretz,3 +united states senate elections 1836 and 1837,1 +baby i need your love/ easy come easy go,1 +butrus al bustani,2 +the lion the lamb the man,1 +rushikulya,1 +brickworks,3 +alliance party of kenya,1 +ludlow college,1 +internationalism,11 +ernest halliwell,1 +constantine phipps 1st marquess of normanby,1 +kari ye bozorg,1 +signal flow,4 +i beam,1 +devils lake,1 +union of artists of the ussr,2 +index of saint kitts and nevis related articles,1 +ethernet physical layer,18 +dimensional analysis,16 +anatomical directions,2 +supreme court of guam,1 +sentul kuala lumpur,2 +ducefixion,1 +red breasted merganser,4 +reservation,3 +in the land of blood and honey,9 +kate spade,2 +albina airstrip,1 +kankakee,1 +servicelink,2 +castilleja levisecta,1 +tonmeister,2 +chanda sahib,1 +lists of patriarchs archbishops and bishops,1 +mach zehnder modulator,1 +giants causeway,79 +literal,7 +uss gerald r ford,1 +monster hunter portable 3rd,3 +bayern munich v norwich city,1 +banking industry,1 +prankton united,1 +st elmo w acosta,1 +speech disorder,9 +welcome to my dna,1 +nouriel roubini,6 +arthur kill,2 +bill grundy,7 +jake gyllenhaal,1 +world bowl 2000,1 +wnt7a,1 +pink flamingo,2 +tridentine calendar,1 +ray ratto,1 +f 88 voodoo,1 +super star,4 +ondřej havelka,1 +sophia dorothea of celle,12 +clavulina tepurumenga,1 +vampire bats,4 +ihsan,1 +ocotea foetens,1 +gannett inc,1 +kemira,4 +gre–nal,2 +farm bureau mutual,1 +pete fox,1 +let him have it,3 +backwoods home magazine,6 +te reo maori remixes,1 +hussain andaryas,1 +bagun sumbrai,1 +the westin paris – vendôme,4 +xochiquetzal,4 +players tour championship 2013/2014,1 +picnic,7 +josh elliott,5 +ernak,3 +gracias,1 +k280ff,1 +bandaranaike–chelvanayakam pact,1 +patrick baert,1 +nausicaä of the valley of the wind,33 +al jurisich,1 +twitter,230 +window,38 +the power hour,1 +duplex worm,1 +sonam bajwa,16 +baljit singh deo,1 +indian jews,1 +outline of madagascar,1 +outback 8,1 +dye fig,1 +british columbia recall and initiative referendum 1991,1 +felipe suau,1 +north perry ohio,1 +gilbeys gin,1 +philippe cavoret,1 +luděk pachman,1 +the it girl,1 +dragonnades,1 +rick debruhl,2 +xpath 20,2 +sean mcnulty,1 +william moser,1 +international centre for the settlement of investment disputes,1 +mendes napoli,2 +canadian rugby championship,1 +battle of maidstone,2 +boulevard theatre,2 +snow sheep,3 +penalty corner,1 +michael ricketts,5 +crocodile,2 +job safety analysis,5 +duffy antigen,1 +counties of virginia,1 +a place to bury strangers,5 +socialist workers’ party of iran,1 +wlw t,1 +core autosport,1 +west francia,10 +karen kilgariff,2 +pacific tsunami museum,1 +first avenue,1 +troubadour,1 +great podil fire,1 +chilean presidential referendum 1988,1 +pavol schmidt,1 +handguard,1 +crime without passion,1 +dio at donington uk live 1983 & 1987,1 +optic nerves,1 +wake forest school of medicine,1 +new jersey jewish news,2 +luke boden,2 +chris hicky,1 +beforu,2 +verch,1 +st roch,3 +civitas,1 +tmrevolution,3 +jamie spencer,1 +bond beam,1 +megan fox,4 +battle of bayan,1 +japan airlines flight 472,1 +yuen kay san,1 +the friendly ghost,1 +rice,14 +jack dellal,16 +lee ranaldo,9 +the overlanders,1 +earl castle stewart,5 +first down,1 +rheum maximowiczii,1 +washington state republican party,2 +ostwald bas rhin,1 +tennessee open,1 +kenneth kister,1 +ted kennedy,72 +preben elkjaer,1 +india reynolds,2 +santagata de goti,1 +henrietta churchill 2nd duchess of marlborough,1 +creteil,1 +ntt data,3 +zoot allures,4 +theatre of ancient greece,29 +bujinkan,6 +clube ferroviário da huíla,2 +nhn,4 +hp series 80,2 +interstate 15,4 +moszczanka,1 +lawnside school district,1 +virunga mountains,5 +hallway,1 +serb peoples radical party,1 +free dance,1 +mishawaka amphitheatre,1 +deerhead kansas,1 +utopiayile rajavu,1 +john w olver transit center,1 +futa tooro,1 +digoxigenin,5 +thomas schirrmacher,1 +twipra kingdom,1 +pulpwood,6 +think blue linux,1 +raho city taxi,1 +frederic remington art museum,1 +wajdi mouawad,1 +semi automatic firearm,12 +phyllis chase,1 +malden new york,1 +the aetiology of hysteria,2 +my maserati does 185,1 +friedrich wilhelm von jagow,1 +apne rang hazaar,1 +bór greater poland voivodeship,1 +india rubber,2 +bring your daughter to the slaughter,4 +yasser radwan,1 +kuala ketil,1 +notre dame de paris,1 +yuanjiang,1 +fengjuan,1 +tockenham,1 +transnistrian presidential election 1991,1 +gautami,28 +providenciales airport,1 +donald chumley,1 +middle finger,8 +calke abbey,4 +thou shalt not kill,1 +trail,7 +battle of dunkirk,43 +eyre yorke block,3 +mactan,3 +american ninja warrior,2 +nevel papperman,1 +ninja storm power rangers,1 +uss castle rock,1 +turcos,1 +philippine sea frontier,1 +irom chanu sharmila,7 +for the first time,2 +stian ringstad,1 +tréon,1 +hiro fujikake,1 +renewable energy in norway,4 +dedh ishqiya,18 +leucothoe,2 +ecmo,2 +knfm,1 +gangnam gu,1 +oadby town fc,1 +clamperl,2 +mummy cave,2 +kenneth d bailey,2 +peter freuchen,2 +dayanand bandodkar,2 +shawn crahan,16 +barbara trentham,2 +university of virginia school of nursing,1 +vöckla,1 +intuitive surgical inc,1 +cyncoed,4 +john l stevens,1 +daniel farabello,1 +trent harmon,5 +feroze gandhi unchahar thermal power station,1 +samuel powell,1 +pan slavic,1 +swimming at the 1992 summer olympics – womens 4 × 100 metre freestyle relay,1 +human behaviour,2 +siege of port royal,3 +eridug,1 +lafee,1 +north bethesda trail,1 +scheveningen system,1 +special penn thing,1 +pserimos,1 +pravda vítězí,1 +wiki dankowska,1 +transcript,13 +second inauguration of grover cleveland,1 +spent fuel,1 +ertms regional,2 +frederick scherger,1 +nivis,1 +herbert hugo menges,1 +kapitan sino,1 +samson,34 +minae mizumura,2 +gro kvinlog,1 +chasing shadows,2 +d j fontana,1 +massively multiplayer online game,27 +capture of new orleans,8 +meat puppet,1 +american pet products manufacturers association,3 +villardonnel,1 +sessile serrated adenoma,3 +patch products,1 +lodovico altieri,1 +portal,2 +jake maskall,4 +the shops at la cantera,8 +stage struck,5 +elizabeth m tamposi,2 +taylor swift,22 +forum spam,9 +barry cowdrill,3 +patagopteryx,2 +korg ms 2000,1 +hmas dubbo,2 +ss khaplang,2 +kevin kelly,1 +punk goes pop volume 5,3 +spurt,2 +bristol pound,5 +military history of finland during world war ii,10 +laguardia,1 +josé marcó del pont,1 +conditional expectation,18 +the beat goes on,1 +patricia buckley ebrey,1 +ali ibn yusuf,2 +caristii,1 +william l brandon,1 +fomite,5 +barcelona el prat airport,7 +mattequartier,4 +invading the sacred,1 +jefferson station,3 +chibalo,1 +phil voyles,1 +ramen,41 +archbishopric of athens,1 +robert arnot,1 +diethylhydroxylamine,2 +christian vazquez,1 +servage hosting,1 +ufo alien invasion,1 +blackburn railway station,3 +performance metric,19 +pencilings,1 +phosphoenolpyruvate,1 +under lights,2 +diego de la hoya,1 +felipe caicedo,5 +jimmy arguello,1 +cielo dalcamo,1 +jan navrátil,1 +linear pottery culture,9 +wbga,1 +k36dd,1 +die hard 2,22 +companding,8 +this is the modern world,10 +cosmology,26 +craig borten,1 +red pelicans,1 +ac gilbert,2 +fougasse,1 +leonardos robot,4 +john of whithorn,2 +david prescott barrows,2 +http cookie,168 +emilia telese,6 +herăstrău park,2 +lauro villar,1 +earl of lincoln,1 +born again,2 +milan rufus,1 +weper,2 +levitt bernstein,1 +jean de thevenot,1 +jill paton walsh,2 +leudal,1 +kyle mccafferty,1 +pluralistic walkthrough,2 +greetings to the new brunette,3 +angus maccoll,1 +loco live,2 +palm i705,1 +saila laakkonen,1 +ssta,1 +buch,1 +eduardo cunha,7 +marie bouliard,1 +mystic society,2 +chu jus house,1 +boob tube,8 +il mestiere della vita,1 +hadley fraser,7 +marek larwood,2 +imperial knight,2 +adbc,1 +houdini,8 +patrice talon,3 +iodamoeba,1 +long march,26 +nyinba,1 +maurice dunkley,1 +new south wales state election 1874–75,1 +john lee carroll,1 +poya bridge,1 +category talk military units and formations established in 2004,1 +the family values tour 1999,2 +brødrene hartmann,1 +miomelon,1 +john moran bailey,1 +san juan archipelago,1 +come as you are,7 +hypo niederösterreich,1 +saturn vi,2 +cherokee county kansas,1 +maher abu remeleh,1 +file talk jb grace singlejpg,1 +count paris,8 +template talk anime and manga,1 +kntv,4 +ganges river dolphin,4 +jerry pacht,1 +rapid response,1 +crunch bandicoot,1 +big gay love,2 +john mckay,1 +bareq,1 +nikon d2x,1 +intercontinental paris le grand hotel,1 +oakland alternative high school,1 +ekow eshun,1 +jimmy fortune,1 +american gladiator,2 +ella sophia armitage,1 +united we stand what more can i give,5 +maruti suzuki celerio,1 +geraldo rivera/trackback/,1 +dogs tobramycin contain a primary amine,1 +hot coffee mod,11 +shriners,25 +mora missouri,1 +seattle wa,1 +all star baseball 2003,1 +comparison of android e book reader software,7 +calling out loud,2 +initiative 912,1 +charles batchelor,2 +terry spraggan,2 +wallace thurman,2 +stefan smith,2 +george holding,22 +institute of business administration sukkar,1 +staten island new york,4 +valency,1 +chintamani taluk,1 +mahatma gandhi,1 +co orbital,1 +epex spot,1 +theodoric the great,3 +fk novi pazar,1 +zappas olympics,2 +gustav krupp von bohlen und halbach,1 +yasmany tomás,4 +notre temps,1 +cats %,1 +intramolecular vibrational energy redistribution,1 +graduate management admission test,49 +robin fleming,1 +daniel gadzhev,1 +achaean league,7 +the four books,1 +tunica people,1 +murray hurst,1 +hajipur,7 +wolfgang fischer,1 +bethel minnesota,2 +wincdemu,1 +aleksandar luković,5 +zilog,6 +will to live,1 +pgc,1 +captain sky,1 +eprobemide,1 +gunther plüschow,1 +jackson laboratory,3 +ss orontes,2 +bishop morlino,1 +eldorado air force station,2 +tin oxide,1 +john bell,2 +ajay banga,2 +nail polish remover induced contact dermatitis,1 +quinctia,1 +a/n urm 25d signal generator,1 +the art company,3 +seawind 300c,1 +half and half,7 +constantia czirenberg,1 +halifax county north carolina,4 +tunica vaginalis,9 +life & times of michael k,2 +methyl propionate,1 +carla bley band,1 +us secret service,2 +maría elena moyano,2 +lory meagher cup,9 +malay sultanate,1 +third lanark,1 +olivier dacourt,10 +angri,2 +ukrainian catholic eparchy of saints peter and paul,1 +phosphinooxazolines,1 +allied health professions,24 +hydroxybenzoic acid,1 +srinatha,3 +zone melting,5 +miko,1 +robert b downs,1 +resource management,3 +new year tree,1 +agraw imazighen,1 +catmando,8 +python ide,5 +rocky mount wilson roanoke rapids nc combined statistical area,1 +spanish crown,3 +ianis zicu,1 +william c hubbard,2 +islamic marital jurisprudence,5 +the school of night,1 +krdc,4 +el centro imperials,1 +atiq uz zaman,1 +sliba zkha,1 +file no mosquesvg,8 +herzegovinians,1 +paradise lost,1 +the fairly oddparents,6 +civic alliance,1 +anbu,3 +broadcaster,2 +le bon,1 +columbus nebraska,4 +inuit people,1 +the menace,6 +ilya ilyich mechnikov,1 +algonquin college,4 +seat córdoba wrc,1 +european route e30,6 +three lakes florida,1 +k10de,1 +glyphonyx rhopalacanthus,1 +ask rhod gilbert,1 +bolas criollas,1 +county borough of southport,1 +roll on mississippi,1 +pulitzer prize for photography,7 +mark fisher,1 +oakley g kelly,1 +tajikistani presidential election 1999,1 +the relapse,4 +nabil bentaleb,8 +apprentice,1 +dale brown,3 +studebaker packard hawk series,1 +yu gi oh trading card game,14 +paralimni,2 +institut national polytechnique de toulouse,1 +to catch a spy,1 +hammer,4 +mount judi,2 +thomas posey,1 +maxime baca,1 +arthur susskind,1 +elkins constructors,2 +siege of gaeta,1 +pemex,1 +henry o flipper award,1 +mccordsville indiana,1 +carife,1 +prima donna,1 +proton,1 +henry farrell,1 +randall davidson,1 +history of georgia,11 +beef tongue,4 +ted spread,4 +douglas xt 30,3 +heavenly mother,1 +monte santangelo,1 +lothar matthaus,1 +american party,2 +tire kingdom,1 +bastrop state park,3 +james maurice gavin,1 +blue bird all american,4 +time and a word,10 +runny babbit,1 +nordic regional airlines,6 +advanced scientifics,2 +the space traders,2 +mongol invasion of anatolia,1 +abu hayyan al gharnati,1 +lisa geoghan,3 +valentia harbour railway station,1 +silo,10 +jimmy zhingchak,1 +glamma kid,1 +bonneville high school,1 +secant line,5 +the longshots,2 +costa rican general election 1917,1 +an emotion away,1 +rawlins high school,1 +cold inflation pressure,4 +receptionthe,2 +tom payne,8 +tb treatment,1 +hatikvah,8 +ol yellow eyes is back,1 +vincent mroz,1 +travis bickle,1 +qatar stars league 1985–86,1 +electronic document management,1 +orliska,1 +gáspár orbán,1 +sunabeda,1 +donatus magnus,1 +lawrence e spivak,2 +cavalieri,1 +aw kuchler,1 +coat of arms of kuwait,1 +wallis–zieff–goldblatt syndrome,1 +doug heffernan,3 +g3 battlecruiser,3 +imran abbas,1 +plymouth,1 +gould colorado,1 +in japan,1 +delmar watson,1 +skygusty west virginia,1 +vesque sisters,1 +rushton triangular lodge,1 +italic font,3 +warner w hodgdon carolina 500,1 +blackamoors,5 +magna cum laude,14 +follow that horse,1 +jean snella,1 +chris frith,1 +soul power,2 +spare me the details,1 +ymer xhaferi,1 +murano glass,5 +michel magras,1 +rashard and wallace go to white castle,1 +venus figurines of malta,1 +didnt we almost have it all,1 +ew,1 +david h koch institute for integrative cancer research,2 +black coyote,1 +priob,2 +piera coppola,1 +budhism,4 +south african class h1 4 8 2t,1 +dimitris papamichael+dimitris+papamixail,3 +system sensor,1 +farragut class destroyer,1 +no down payment,1 +william rogers,1 +desperate choices to save my child,1 +joe launchbury,7 +queen seondeok of silla,11 +adams county wisconsin,1 +bandhan bank,1 +x ray tubes,1 +sporadic group,1 +lozovaya,1 +mairead maguire,3 +royal challengers bangalore in 2016,1 +janko of czarnków,1 +marosormenyes,1 +the deadly reclaim,1 +rick doblin,1 +gwen jorgensen,6 +shire of halls creek,1 +carlton house,6 +urad bean,1 +baton rouge louisiana,39 +kiel institute for the world economy,3 +the satuc cup,1 +harlem division,1 +argonaut,2 +choi jeongrye,2 +optical disc image,2 +groesbeek canadian war cemetery,2 +rangpur india,1 +android n,72 +tjeld class patrol boat,1 +together for yes,2 +tender dracula,1 +shane nelson,1 +palazzo ducale urbino,1 +angels,4 +double centralizer theorem,1 +homme,4 +world heart federation,1 +patricia ja lee,4 +a date with elvis,1 +saints row,1 +lanzhou lamian,1 +subcompact car,1 +jojo discography,5 +gary,18 +global returnable asset identifier,1 +aloysia weber,2 +emperor nero,2 +heavyweights,6 +hush records,1 +mewa textil service,2 +michigan gubernatorial election 1986,1 +solanine,9 +andré moritz,3 +foreign relations of china,12 +william t anderson,3 +lindquist field,1 +biggersdale hole,1 +manayunk/norristown line,1 +aliti,1 +budhivanta,3 +tm forum,4 +off plan property,1 +wu xin the monster killer,4 +aharon leib shteinman,1 +mark catano,1 +llanfihangel,1 +atp–adp translocase,4 +tótkomlós,1 +nikita magaloff,1 +xo telescope,1 +pseudomonas rhizosphaerae,1 +pccooler,1 +arcion therapeutics inc,8 +oklahoma gubernatorial election 2010,1 +seed treatment,3 +connecticut education network,1 +company85,1 +bryan molloy,1 +roupeiro,1 +wendt beach park,2 +entick v carrington,3 +firemens auxiliary,1 +shotcrete,14 +sepharial,1 +poet laureate of virginia,1 +musth,6 +dragon run state forest,3 +focal point,10 +pacific drilling,1 +intro,2 +priscus,1 +rokurō mochizuki,1 +bofur,2 +tiffany mount,1 +thanasis papazoglou,12 +life is grand,1 +ergersheim bas rhin,1 +medical reserve corps,3 +anthony ashley cooper 2nd earl of shaftesbury,1 +uefa euro 2012 group a,32 +america movil sab de cv,1 +christopher cook,1 +vladimir makanin,1 +file talk first battle of saratogausmaeduhistorygif,1 +dean foods,4 +logical thinking,1 +tychonic system,1 +hand washing,17 +bioresonance therapy,4 +günther burstyn,4 +religion in the united kingdom,35 +bancroft ontario,2 +alberta enterprise group,1 +belizean spanish,1 +minuscule 22,1 +hmga2,3 +sidama people,1 +shigeaki mori,2 +moonstars,1 +hazard,24 +chilis,6 +rango,3 +kenichi itō,1 +isle of rum,1 +shortwood united fc,1 +bronx gangs,1 +heterometaboly,2 +beagling,4 +jurgen pommerenke,1 +rockin,1 +st maria maggiore,1 +philipp reis,1 +timeboxing,12 +template talk tallahassee radio,1 +aarti puri,2 +john paul verree,2 +adam tomkins,1 +knoppers,1 +sven olov eriksson,1 +ruth bowyer,1 +höfðatorg tower 1,1 +citywire,3 +helen bosanquet,1 +ulex europaeus,4 +richard martyn,1 +hana sugisaki,2 +its all over now baby blue,6 +the myths and legends of king arthur and the knights of the round table,2 +dooce,1 +german submarine u 9,1 +george shearing,4 +bishop of winchester,3 +maximilian karl lamoral odonnell,2 +hec edmundson,1 +morgawr,3 +sovereign state,67 +avignon—la mitis—matane—matapédia,1 +duramax v8 engine,12 +villa rustica,2 +carl dorsey,1 +clairol,6 +abruzzo,22 +momsen lung,10 +m23 rebellion,2 +kira oreilly,1 +constitutive relation,2 +bifrontal craniotomy,1 +basilica of st nicholas amsterdam,2 +marinus kraus,1 +moog prodigy,2 +lucy hale,49 +lingiya,1 +idiopathic orbital inflammatory disease,3 +shaanxi youser group,1 +apeirohedron,1 +program of all inclusive care for the elderly,2 +tv3 ghana,3 +arnold schwarzenegger,338 +raquel carriedo tomás,1 +cincinnati playhouse in the park,2 +colobomata,2 +star craft 2,1 +yaaf,1 +fc santa clarita,1 +release me,3 +notts county supporters trust,1 +westchester airport,1 +slowhand at 70 – live at the royal albert hall,1 +bruce gray,2 +only the good die young,1 +sewell thomas stadium,1 +kyle cook,1 +northwest passage,1 +eurex airlines,1 +uss pierre,1 +feitsui dam,1 +sales force,1 +obrien class destroyer,5 +sant longowal institute of engineering and technology,3 +united states presidential election in oklahoma 1952,1 +edyta bartosiewicz,1 +marquess of dorset,1 +whiting wyoming,1 +akanda,1 +jim brewster,1 +mozdok republic of north ossetia alania,1 +maritime gendarmerie,2 +paresh patel,1 +communication art,1 +santa anita handicap,2 +dahlia,44 +qikpad,1 +pudhaiyal,3 +oroshi,1 +ioda,3 +willis j gertsch,1 +scurvy grass,1 +bombing of rotterdam,2 +gagarin russia,1 +dynamic apnea without fins,1 +loess,14 +hans adolf krebs,4 +poręby stare,1 +kismat ki baazi,1 +malcolm slesser,1 +blue crane route local municipality,1 +jean michel basquiat,104 +customs trade partnership against terrorism,3 +lower cove newfoundland and labrador,1 +aashiqui 2,6 +elliott lee,1 +edison electric light company,2 +i rigoberta menchú,1 +battle of tennōji,2 +transport workers union of america,1 +physical review b,1 +way too far,1 +breguet 941,1 +manuel hegen,1 +the blacklist,12 +john dorahy,4 +cinderella sanyu,1 +luis castañeda lossio,1 +headquarters of a military area,1 +jbala people,2 +petrofac emirates,1 +ins garuda,3 +australia national rugby league team,2 +state of emergency 2,3 +mexican sex comedy,2 +baby anikha,1 +notions,1 +android app //orgwikipedia/http/enmwikipediaorg/wiki/elasticity,1 +kissing you,2 +montearagón,1 +grzegorz proksa,3 +shook,1 +may hegglin anomaly,1 +chrysler rb engine,2 +gmcsf,2 +blacksburg,1 +chris hollod,1 +the new guy,1 +thulimbah queensland,1 +sust,1 +knight kadosh,2 +details,4 +nickel mining in new caledonia,3 +easter hotspot,1 +surinamese interior war,1 +field corn,2 +bolesław iii wrymouth,6 +lutwyche queensland,1 +michael campbell,1 +military ranks of turkey,3 +mícheal martin,1 +the architects dream,2 +joel robert,1 +thomas smith,1 +inclusion probability,1 +fucked company,1 +genderfluid,5 +lewisham by election 1891,1 +net promoter,98 +donald stewart,1 +xml base,2 +bhikhu parekh,4 +anthocharis cardamines,1 +vuosaari,1 +demographics of burundi,1 +dst,1 +david ensor,2 +mount pavlof,1 +vince young,5 +st beunos ignatian spirituality centre,4 +ezekiel 48,1 +lewis elliott chaze,1 +template talk croatia squad 2012 mens european water polo championship,1 +the voice of the philippines,4 +whites ferry,1 +cananga odorata,9 +man of steel,2 +john michael talbot,2 +superior oblique myokymia,2 +anisochilus,2 +e421,1 +midnight rider,14 +matrícula consular,1 +first nehru ministry,2 +christopher mcculloch,2 +ems chemie,12 +dominique martin,1 +university club of washington dc,1 +nurse education,5 +theyre coming to take me away ha haaa,1 +bill dauterive,4 +belhar,1 +heel and toe,4 +university of the arctic members,2 +mitava,1 +wjmx fm,1 +father callahan,4 +divine word academy of dagupan,1 +bogs,1 +denny heck,2 +church of st james valletta,1 +field cathedral of the polish army,1 +indian skimmer,1 +history of british airways,3 +international mobile subscriber identity,38 +suzel roche,1 +steven watt,1 +duke ellineton,1 +kirbys avalanche,4 diff --git a/tests/testdata/will_play_text.csv.bz2 b/tests/testdata/will_play_text.csv.bz2 new file mode 100755 index 0000000000000000000000000000000000000000..e3bec9de829086914c2f4e35f6ab38b750648b44 GIT binary patch literal 2069623 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

(C#Jm)a9S>_z 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 z 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

f}>QoJpd_n4Od{*^q2OeJE((kHtHjXT$Lr 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

`|8C*AyP=V5y$+K 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

pv;NErTN0 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

7ljS$;-?C5Y&tFa~$qpvF)rUP*Q-XZw%5!-3 zhrUqQoXQ?;3{ui-aqB1B^`WMZ`n=r0CS+=xGy)>TjsXJZ!bstmZJqC5+dn2A3E#tp zM0-lZZ;6Muk6Kvt*b9i$7_+a?6;V#o{uUx_J zO?2*`g!j9jWarZSDl|NAa}U4j;A~D`&I~XP2q$L44jD68N%%JS%$J~SNTb;iED4H^ zq(Qm4awn#d+z*((I`85YXyou=gAtQqCCqhRpjut6ZXJmP-iuoiQHRwt@be17pm0nj zp?jx$McY4H(V<)OogH7-qo-N=yiw^i99F#Z>1CjKKB=b0*Yv^2CqS^rZAN4@+ld}x z9KkH*&HPVsszt#Bcume5x2m6-qI#f6(H#-0oTS7v@e;RnnYrD(AIZ*Fe*OFFzfs+{ zT6dFTF6?KGK(0ov=Q&moUscW(UjHTaVs< zB3kFegh3L5;V07NM3Vq?{bn1yj|jqh>|fyK`(NyGes{N{FJZLA{)VwDi^JIp>StAi zp_tQle${ZoT*yHFke}kH?+h3T2q^N%1+S72KXuHIGk>P*?6X-AD0(pgxqb3pL{85F z9-;Jk8iMVI=2?M|Z0y-E?LDU2!Msv6;{;@NnmMS%(u7MOUNc?Ty^SYhUc;Jp7#dat z3AMSKz3$Hf4{TR6UAXAV6PnXS*|}Z?b;%^&d|xN**Vi!h<$28O;`|YZvy^Z;hjU_s zSGL=MM%I6pU_d!*e9&RZ12n?K+bkMnyVz%kdr>UFnx^o1P&@0an~t8D@APT%Z_^K= zaA$1v_^Gcfp`T68nLQ-yZPv)5tK)e0nckTGTB5$GMNGS99l&Bb?cyldN@+H^cVVBG zn1^BVm|(+}1dcpJ%!7V9jwa>&O99b>Nj4yx32!djNNM2gEQP@UgGxS2GGzdcq)zy) z!5xu7*bJuaFcL*r1_X58OY3YIHLk}@&KevzAj32>2tAkbMva4zX19jsnu9gF_^7v{ z2P9XlI4POgh$7@u&W>G=lN8ot=#BvndF6usDSjV@d~G{rw5qB1O!IK*es9}LX$^%0 zAEjW8(RZ{de1RmANyf6jzI}3uBtI;_ehxYe8p! zT+LxsUhQL9Q_eYt*2**AtCQ&VI0^Uk&XfB5XmNq}PDJc_P5V7xSa>)1ExtB2=x_zr z?WCa8Ym;1x@O8IT3bOJ?-at8#1#WsaOPvk zlxlIvMaYa5={; zgx7!4bUt_9JxhQ#M)?Wl^~L=?{GhBE^^=K<#4N~avkQKz&0t}Zd#f=9PqH_5J`RV7 zI)>BgQ6+w!M_2n#0oV<^`C-pA?}y31-23=wzwhM(EjwIt-`ePTf4P_Uv#@%x{15wz z{~uPARE;;xHKC5D;>OS{EAU7xJb=#R_={Ly@9uZM?@h%Io21IsjExFGnPa@Q3^qOx?)yeE2A!_n|U2P+Ru>Z$cuO) zMBMjB8Q_a>`b&U)71{aZ^WSKJ`t(r5^FFavP+!4ut*)M@NGgdZm>dD~^NGy!-~5+* zeR_;=%5Uv|e_(%@C0D6Tlz&7YK7Fq~*mgc-_mJlfN&LcDiOh|pa-pMmdr0m={rJs5 zd&qN@=O+`#eEschzM_LNCG7S0ar#1>U5ns55E)BiQorbajsIlNd@S*iwz%W$=y4d^ z^Q9Rb*Nm(;CpkJ1nPR)*dHdgghFlw;6Pb}x@C?0C-ksIf#WM47&U(H%G*277o2AEN zp)A=WJn^+I>o?CrhXwY#@6Mz7-aPldap#X-aO_Wa7TzL79EuWOaMete+NVqQ>_*xZW{(M=P(8>exe{~v=PhEoKF2Xe>J!8 z3W(bBZ+R1J-gV=%UIDuAeOzO#>G{Vu>X70*nUv=IzanG2;XO-Bb;mp3T<@0N^?W*s zy-yGmW3Ba9=dPcr4ZxPSewaje8O+8D5gkM2MWElsUm;(~ay;dRVTTMm?)R}dygpgO z@rN1ZiGB<{0>t>&n&jzXU}5sE@r+(!q@F82VLvZ^@yxXDi@fQGXlcUnULU6vPPKSX z66WVn)}_|eo!=VPe8X+IP1A7owypP($maL1Id2{&rSTAT=l<|JBQG0?r{~ank6d1F z+vAz=8`jk2^T27}MOQ+nQ3c`Mx97aeO=Px-V5muBP2rk!5syhBnqA|@viK*iM9jt< z`tT!0ywhSd4{>_jb|Nlt{NFh;O=fT5xZ{d*b-?hZvwRTxX+>Uot;Qy|B8|brh<=`} ztHt@t^!3Hf=%P5&k_n3LxLnoHXbxSkbY5mbzm2N*!pq2U)@x|POOcB@Zn5W$cb>*& zUsmkuapE;2fIszETl%p7rhpM8fgXOqDYR(!7w~tfqM`_<=|rHmShY`#wd5<}!u7n3{rR^pe#3vy z`APvHM|<_`*LdBg@b9CTa)vguh_ko*oBiuKAISf`)$t^K17u6Ec>ZGx(a#8BwRr0e z@w#`d|0^;ve}`-l=)t%AXw50Z78*A);hDDVOtA+x<%jC_YYP7k{r}9|KbL9Ge{ePo zoe^Da=iRbppf%Gm6W@r(p1wA9kMZ$^PV_kQhsx|Mj)y+z?-ibo)p!OEAHq2_Ld8B? z->CW2SXO;ef&LJF^Nb%K|N8S}1zqmgaPg9(vAu?sNKjaCdh7 zU)~LR)+dk8FClZBi+(ZU=HwfRPy!s{Wg5x^7kEl*iGm3MDa)HID$wc_RN^>;y)_ilP2{{ zLlo>tF2c+mhMm5oeH#c8XTw>rH~Z@ITvH1hLd9Ml^6uF1Xi*%q<=1mg_gjecefrbi z^u73n2f@s|^E{L*KFH0r0 z5N!Y#_My*DOw5vg$AciNoV&5j>xVSCI_&0~mFqR`cKm#D9Oz>P0`yooQ6dR2U;Y4@ zG!z=#n$o2}>`r&zpG{tGmyEoBX4FR@h9my0#M#Sn$NK5rnadCEd$ESL0kWM)VMq;9;;zagP_hbB;B7^#vA6eh?opb`e;BcSp z{tx&?{yD$Ol_^hspN}NdQPsIQ_V(7@?#aJQ-_!7D`W?>df0Xo=sZoAL6h(gNL|}-b z6k#hnht21c{9b0B+z4`DeIl2j^(qIesr9UzGLj&V$clb6srHcEBoApEpG+bR`^^-E zQtHyZV8S9ca!kTy?nG7e#|R*@GcZhGqDUwD+Ngqv$m4(K)^)Os)T5N$(q|g6QB-D| zg-8B3G94BK?I0{CxE~+h8NW9Lixp7i5BFzA4?7Q|I8JGv{`Ind)g~h9Q3q**{2|g9 z+bq+Wu^8rWwkH0frwQ|Y4~y@m{=p&<>KuFQM`QU==ePPFzn=$~9DjX)KJbU{*Zx!H zAC7=;z)nD!Jc3KqFHuXBhv{(san!%TdU57`AX6YZKr=mM@ogw}mzLNNpXZd2K@C;Z zY&+@Z2DH~~>pgF*=WPxN!qD9HzgM`6eWmsCHViH4!s;P4!uxE z5grt$q$$5 zi?o(`;hXa~dCwpYa0LLgi$Vv;Jzlq-|zb~lT`2X>Q z@B7@|8n3&lrT*=$zcp9I*|qoke^RE5M1K1$hEzZYYa@g zVY$>S$N>-lBmg+qvu1r}EX*o)L`_Wy`of}WKq(c}q8Tb=6a`WzB>?_1FHnQ};6XQl zNNctF-*2J$AQNJmzD{w)06DD|_5 zqj9;g#)aWyPFIbK0+EuUSJWUu) zV|L3PO&1)iCd+wjfM$>-6PU}dIKtKiukEd1pUw4P8@73ypEL03{iN*NpOhui^S$e} zI)Eu$mXyd}Sst-kIzpO*P@rb95|>hCkmaa(9{KNXj6%S-x!J@iidWK(eosf z)OvN176-&tArnOu(5wQ;5fwEAv_wlp z$K$ho0(?F9%pfz+fTC8t9EY~s@s!yEfTTw|DzNqH9=T4K8iK+U7vG!;1i6R?5s)+~ z;3WLR+6o=b@?aa5NWdO42cE#emIOMUL~#cay`kG-0+i`0c?45I5C%y?9H0!4eVz5C zH;v#aU9O=+?bFsESG&Xo!YNq@op2PjsjvA`1ns zVQY*6nNV2BrLV8IYn%PI=7h$^U|9IB&g+gz}wjB^Ai z4jE!DK}$p4D77I{R*gwx3c+d$A_Yp! zaV9cWWu&N}V;gAOWne6*TXB%63QTB)VHOy;SX`B2xU|%(wi>`&Xk#i0R%RGuVo{}S zqcWBX3vNuofUs?HnS*O81{q+2fSGb!Gc9on43#QOM%6K@f~gyER@}x7%(E$D1TiH* zMj{r<3omvVms^%7D#qAXnXXMp5S1Qrs&ROnPD?~msEBeC5`!>Rj#8k+WwzP_FpKou zKTmdYc%IT7xR0wrAO{Ncv!em5l$q@bpd8{#uHCwxvG9S-1hdRca$&x-oQ57&OGYS? zB{|2h5I7yIsUs$7vfCnAc@$0($|AsH1}qzI`fR*Ddz36P?wS_Y}A8fa33+L?_Y($={jziWqgh!ZC<-2XT#mX)BY zN(hFULWrt?DWE1)qK2tT3J1?X^hfC`4~aB?v=mU}RGd7Z8ahH4(KCGraN$_9tbz1F zWIZCjSFT}2Wfe69ffNw|_Kv*EWOs&z*}!8j2sp#q!O;g$g#(B{g+!DeXfV{_*ZqB8 zJ!>&)wPaI3qBSvBGbC9G%Bd<#XJ!#wIyj12Ane)}*Bx zRykxSz$H_G6b4GXmKGe8q_($_5DSJYaV@M>AxyInkN_q%f}$=e8I%l4f{t3ng4SbD zSr-LhwZ^j&Wu^&gmR8$qLZxIuilUimh?TV!a?>23*rWxDtV*i9BPmu`NkO<{6AJ}Z zm@-xsEh&Yf;~HK+K_QKH>QcfuHl2Ijz87U)LOjaqGM=4QY+7!&g z8Chy74Q-TVD`iw|mkS|N97;J`BEnWAVG>1T2o-t85bBg5Wk{r!l}NcV!sQ0pM-|L0 zP`E14!L+!_DzK?aTC12~vjlC8ux-IgmxxRd6o}15ScwfeTLJ@KSBxbHfP`gOMHbbV zm>TA2g3Ee0g07|E%rj8!0_ zk!4E7k!G@D1|pF~GdA3fYvV(f$xzfWkaEH-C<01|fT~uoP!!3O#2}y*B}oc}L10xv zp)nO)EHMK)rDDW!7$BKRiGgj_R>X*+h$yg%OKOCvRRKbXC@o<`teDEkVj0S2(n9To zF)>*xs+2hZV?``v23a``22K?wWVj$ys+EY9SYsTp%a|30F;+o9Db1F1DZEIGGnz34 z2Q6a4D7e;QTP0-FSt2tK%u1nHv1BDcX<;doS~aM$*i?dBDlAsewvx3BH4DPYh@!|T z6%~>i-6(@tDk6nN6a}_|k~FZaD;DyFD`GPlj4_F9Suu)Xk+TqjlBKq=#I6{YOlq-8 zk-Ea>+F})gqX1c$Qxyn|%r==Js0$P|6t+tg1kA}IfU5x*7_BBqa#JL73UX69f^rg* zl7ncqmgUHZh@dQs7?EySnPkMvV2lc>2&Kyd8I5JOV=O_rw~r1X#D-;y3YZmasIVH& zHHHy|R>ju_j597I#I}QtF;YQgWDzS^7&wB9Di$QhMlxe71USGc&AepLK(@xxELkCO zwW%YN5mJniU>TPXQdEUhltDt{0T_cw3THXYcoTtAMKDao7^E8@V+_WY&=w0Z0Z0XF z8!$oTlSrgBX}AzdQMN^ug+QYqs}w|vvm1pfC>Y6AScs$+M+QaSTw7$SwWYQUQCaD@ z*~F_7t;>e(2W%@hlnfa^qwOQM0-=&*Cs2u!;R}$b;z6ZDB9v%Xxe&@51N z>UZDT!d0@uaHJ#{tV8-DR3d9jMaxR6_k>(c0$vWd@Vg`FIB~E|0Z0e9q$yVjOn`8g zCCPx=IKNmOFsC^lPY46?B0iEk`BMHwEi4#;*7!q`}{Ar1k+s7@y>YSWa2rz61{Nbsctm0%6D zoVNFdWGPuRRbZH#El1#cAb#oDDPx} zMAXt!l+i4;6B5NxN{FqYL`7mCj9{LqP*T|BL_xwyG$qL>AJ( zMhjG=Ni54jOeB#9ECLFOWTL2sD43F}iXxen6c=e+VO3QHP*qh`g2pPVHJFA*+Ch3t zjZqX)1%k|NWo65P2#87YoM1(O7{lt7UN8#95s_dvou*}?3kryCuri>b?lGvPsG1n5 zxsDM*OC?QFOi7kg1WZ9yRTa*-9GXa&h6$ypDlRn3K^2G}eib035t3)lKTGw(u~p}N z7%Ms+WGw4!#%*&mErumIf+DHZB;QZDOZZ zSrRG}sie~-%)2uXBdH)n7!j5hz(6wQyn?3!(kQ6$ZzX0x;*5*PgaCo4t8&2-v_jm; zMk*thtP3*L1Y#nnvLgqN8JAoMY_cT_H6|f~N|8WO3~O#1mSFBoa{mo*YqzEuw7v)I z9x%fgd0|QL%*q9pg+z>G7*pw6Eh?g@s#Y18W>9QUG!%^?L`4MyNl0%!M`ir_cZ_^y zkAJHT^?oDbdLmMRX-dc(sA33|h@%1wsi+{LqG<{ONkWvs%nvAOc)E7<>ATiSs3IVu zm@2AD3R#$ff+`9kS{ev|ihznLBBF|7Nsyr=h@#-Bkfi*sI zg|H?<@yU`VCMr;fqKVIAW5)Ec}1)qvy8+smBfLTtXV518B$jg6$2!BAoo0gkCBLS0)e0! zP^1b0&fuZQI)sA}FF6X)US> z+e~drim1j@E?Eq`#{wXluRuoYL)nsH-R5cZ4lBp4jvk|ozRJe^Oiy2u} ziGnDWV2BDPf+{4cAt4G9N~t1BYbB!!B7jjvN}{q^6cn~uTUa@U2oYqaOUo;m7!XY{ zMFk|#P{k8-3UP{PqM{~_L6}v>qPVQ7S`4iN5~LFlFrqSXsR^R0k|>&*qKGUO0x%~> zL4e8d*Gpk>%9+i~6#-#UMHGIwrz;O$TyUTQB}*CjlW_$hseJSZg0U485}Jylh)SAD zh$@03*}tGR8yy78=I$Q5{eIH)9PESk5oG;Zf`iKnA=0zS@8ZT4sgedpLS)hTKA%qfu&~W-xxjf2LG3PviVZ4sqJcX= z-{JdwB|G`q(u)NVUqdWAr`Zpq#1x%}?32$&pJcYbvRq|tqbp%4z`XkLs;z>oPEz1G zA{G&C#^k2@=r=!BKB^~B^%yKr77S>r04b)1fhcN~ph`+6nwXN6p`a6a}sZ%Osm>`xRD`TgFFDC%hHA_iFCPT2nGY75-gvM$G zmWo5RLZlY~50kF)yr`jxrW2_#d3?3`F@QQSIS}Ab3c73N+27Hx7_Y zP)$rkR8p7;sZIdV&K^oX>iVKWPItR|#4XxOEY)Sq6pVE<^I+ z4|L5)N`O^Pa>?hSVa-W)ZZu$)&I%$6^uJv^0 zY|uEGfNTTDL|!`XXexWzzuDgq;o_Pe01+cZoq0QG9Fu7})Ex=muhV_mI_rcy!sEPC zuI%Pmc<}$7!$O?#_k86(mh=cKeYlb!fxJ$d4n`RNwb=hu{^6b82`Q*QwxM_w=7Pwe zD5r2jQ!B8Nroj}A%Hum3cMf;d-JmO)V;!5qouu73UVm?(N$GB@ZtuA70>@7v#!dB4#9E=QVDxLS&gS^DPWmA0<9 z{4JoQ%_l8PiE`$o6yF^#QR5CFg0d_CJ8v<~FMxU9(<3#)6~x2_;z#0^!RTD7@wA&b2`^;=K>El^__Lh=)~t=t{oxLvyU-4 z$%=PtA)j$`#jJfxJ3rho$ctX%rxt<{17=3F!SQx9TfFptTyFliW|Q-!`@aQBArTV+ zr`+R~AL+Ny4w3T_XO2E~{u8GSwS^_?8-KROi!J=r{FBg&FEBWsaeyJf2Pr%g^x>28Gy|o^# z==ehP@dY@5941b3>F}LCy&H1o;z2slnb`eC0o<&V5K4jl9xvRaJ_X-v_ndtRX@(a6 z7ThfjcS+ggz?9cnK@t)A8w|8yHux%!W z8kGM{!Ujsu=hx5Il;-^2kMI7J3j_I>AgK|1KlPqhIRO>ixz$jRT~)=)1ZS>_H^Y%|1Nl(YRUv_xVO)eXjTtKYM$>h+5Wfc(zk`V~m;)+BViiI*ln8Qg#t{KXR z*I-=Bo@Bv*Gu9V90h1;i%spvNNPKmGeYy`yGB1{>R2z8GS}^m_e1^7zw=K_)Dh%lN z>SOaL5dhL2N{9+%IR;9EO(s(yQAtaZWZw7&-;W}){6AdO`R`Mig?8jNHzsD}&yNUeRZqj{> zcXzB?zn`){+=|E-(^!H7aKXU@KKa*vf4evRO{b&d@YMQK^cT-|7-if&@PFt2_ZXD0 zqKp&~Z_m};tv=mSMF?m2vKYb&7{Ydh#u&(n6<9G;I(nx!N|+;@!b-wq65Bha3`q@P z1qoH-sSHRX0Yh0(sZb4Jh)abs-c{t|Y(q|0FvqkH^kLt1dDBW#!>>)q$>im2oMPuI?u`oAG8m;YGkL`PQCB`ZWYi1vN(UcURDj!-@R8lWsSM&%mY} z`gh~LxOWdh;4$|#vk%4V=6oU3{%6^K{qc%!J3q;{zZZNP zN=`^->{t$sfEhLbn+G7t5u{oMl>_=nnFC2wQiV`*Lm{kzF^rH35TyYSf`C>-QWXfW zpQvb`=RHpWDf{>w59=hoVr0=nl01;(Qc_4|u*3;l82;M)n*hXz(9RyYo*V4#IP%?j z&@>PqnHoDMB|;1hqw%1y#03xN>9mTaVW>%+QA4Id5&C=Je^;K6C|f72quqg777RdB zAUR6?qiARyPDLC-`@-r^Y2?gE9|;8%I_qbOgVo@SjVzUS>7X4!&Ucr24kQoe5aXr^ zl@`!^xH}mh<;hZzXr`9t21O_3eL{65=5kKpFLo4-3rVrutf&jt*$FX+OdxU9OuspU zAS{_=lm>dR$$dz0?%NtUo!B&n$&sD9g|>TdEq40PPp76FNiVAj!)3O;+RU|2JGh)V za8?!RCLU0|CrRl9MV#l3dTd!cvK-ofE5kom(>H0v2nM=@5LyCS0ApOTE?=-bV*JH~ z3L_cDRv#!FSC-VOB;V8C2B20&@9J&pUcb=Wzt2Z`}Zo0D6-p0nu;nY#~`Yh zc3`eOwAPYeS*N&1<@xsyU(-9CZ|C6VIXgiX)?A3^2DDPKo$mXsy$`d*>Dj`Qx3k-& zohNTR+&FFG91zB4Gk52C>&wkMsenYu-7l{1AkTB<2(X{GsGP|6>pYTqlI~}QKR3kg zbLXkY{Cl{M^*`4A&Gnw%x~BwDIcydvtBk2Xj5b}{@wTICly#CD4`rn{I9%H zr^UWm-OPQrE_~AxOp)lo#xKP~!Y}BB&nY@W&6H8*pn$xeh=|OXzWl>FsrBam-?BsQ zlzM#l(++a2m$1PD#fP(N0NfSt{}0}|kNkkO?+z?Jf0rK5ezNxi*%`jvH@qq;@AD7D z*yZ@#9AV%waCIp$|EJGzBII%^8T8RM`740Ud~qgts-axHe%c>(wU@9-<=^h#GQq-B z$GXv59*=TAf92IN<&dDZ`i~V9>&LDIviQivbH?#~)-`pUvW#M%(wfdSb(Y8)m(X?~ zHv)cb3;j&TH9O!xJ@QPAlCn*@5Fz2H#4a5+#@;V;pu(I+X>4J=XKTSyA~0jkR;sQz83D$gN@?xEj0c4i7`M)}f#z%=25(vx24?2wITdsK`w|wo(UDobqD;_4hjh3uN8fc!l;{9n1 z3uW@q4V|T?HMBX5=amTe*Bl4LBUpBhXQv$AtT)a(*CU=7Zvot7+|cfYVYFPgVY%mw zsGZa$*w?m^%m1*<)= zXC-+K2DMM34d(Vbc2h|nZcf+!1++@9zu1L{7)(8 zDM(VpQEiI)&5jSA#shD*ol^)z@_RJ9`v;SFXqZ|KKN=J z>hTdLexKGk2u2>zVnYSzF%wKNdgGKFN9G+ck-r4cosZfKh8;&22N(;qsi#a6%PPBg zJR!ok{rvm;$H;$guP$C#Qc(7>nM$!-wVgC?tB6`%WnzmI{Tcr}u#&On5`RARoZi=z z`{t98^`%3^F;GsCzu^pc$~0y7YXEW5={RhrRH%rJKUGwp)ov3 z5tFnq$}F7*AGARe=O~^ZobButYAo7RiS?)CeoyiB^cT^@f2==b6+qS31qRv(tP}{9 ziX?Nf?3&psA?{<`oKsWK1DwyGl8NqLN_3RheOliC9rm3O12U zWHCfX45MlawX#$S#e`jCq9E!=s`d}?_e0{EA_(C8`gvaF*|sKU^Tx@kk3jx*hUs#+ zoo*=pv-l4;mi;eu@bm0Xd~%7WMtp(&nP^Wfu7n}b;`w*}e1zMZ6&4AJXcqda|)lMhnUn=_srWvWwGp-C8Jdm@qX8#l|d^2Q_NdWi5wk zvK0#BB&o|p7^JF|ibAwhtg5Vn<%M#QN~lCgpo7(@$&ErQ8C%r}h+vJh<%T3v5-Pz$ zp?kGs2%s#H1T##)axMS&raWP_uIgV-YI}SPDfI5+;p-%L|PJj8Q;Pqga|M1sJ(( zjA8{|P%0TMDis!Dh)RbNpkgc(G*&!A)WD(-8AN$Xl`#OuMO?LI87m?o!3rRzGf>7G zYZ$G#h6_t5fSW)ni6XHzD+FM%W-zFu*HjLb)1_<}K}1bvWt(ft6vUzYH15cCl8PuR z(oqzKwW$u16h&NMV*?ObKhZ0UB^BJT$vI)H#bV~%Et1ZY42K+Gfs(wG1M!R}R&Xas z%G$z@NW-EEjj)tO<0~XBD|)5^^Quf>kk(?D887K^%XdLd6wnkj)S`+RY()|e@;2Zq zjB?vc@|)(FHkWLm;vblqkhL#)fT$xCDE{^uaKiCB+JnsWljERZU!2J@AB<~R|IYvh zw!cSkY~~)n&V$~FE?7l!^ePw!Yfv%C27Scvh*oO|%8ICkN195Zx?OBq!!BDi4TItHH&_7Jq%oHE0&DkMoB=i``jRb+f!dS zWy6$ceXTy}1N2}X`FNg_^+K>UKhws9sSRWuu&F&|uidFQyXzs-ILIindh&sZ3}lCY zOFgqt>FFM?j{ez%4H$2uzOg>r^3r@s`IpKAya$e)wa!4^hsP<*r`0MPc>TL>j^(4T zEygK3`LKUh0+Htd?Ex2v=?^2PmYPmP@_dr9A4z@+6+CMMRYkvMm{g)Fr?jvj1c$YG z<>S|V{d6zV(e3^jihDuQ?*};V*Eaobdab|`cia|{W>X^=L{cdd%E_X zm}(cfK<$U^^q^>q79BF6yuJOiJF)$zsqu=Y{Qlpo>9kD_L+xqJ>#yx>d+uWPSS{Q2 zp5LBrzS+TfIGM)yXV*Sw=buB&Pl*olA%O)g4Fe>#MLn5~G&BjBfY4AS0JMcrhC!7e z6eR{gnOTNH&`nE0K}11JQ#D9TqLduwFCwUD3ZP1$DkG8{kQr$j5@HTY9(x7v^By#R zLoi^dJ<{NLZQ1pqAGzJ2>6(ZFYC%z7Y`7UbANQK}LFFSaBr@FGn} z2+{hH+rC8`Ti!)ZUt!illR`L@^#}z=X$eD5R)+jd{88h_gK79Yj zf8fpxy!`*qO@i&~Gl{wMWnskOl5QQm(=*V8U{MhUCcdqo4H-IX=6TU{-DiibF<#K= zspA7vdpmjAH^r;T@b1{0iF;qQHs9)Jy!B_}@t0NXPe>K0iL0!mRg|?u^tDXm$Q$m! zxGaky_O!G8{`AiK@4fSumG4xSI9F~*FVhbO7} zcf*Z7zL>JF|v(V#{`D`X6TBgZQxijjRS9&cZP8NNN+5I8;&; z6+`I*t{V_+l@uqYRUz^D_3Xa6bWKCn?>cns=_LNqU?lb4thz+!vu$ckh9xa;d%M~@ zA5OKMSE>{3KIbVEmOakPwwI@mVa@F8XANGB=+m2-MdFWM-3;pA)3e=Ig{B2b}|s@SQ$92Y~R{ZrW3jDSGd{?Jv(IgoHVW9nBc2 zl&I~hGj-Qo?(+wOF)k(UkDIuBc3x~!Exerg`rXPyav@oX?wZb*cW8X_KKEvXhc}*O zk@C*=^oby|?8%PymTLOxWXE{662p&K(*5S?_ts1({aeOnTf{2gL6hP*RboZfFwC9Z z?EA8wtihhtCu!Z$>wD{R_b+Ko*CVtl_g??@{)5DfQnb=hMHJCVOc4b0lx8AY6<9T* zDwxz}OJE|fp^};8JgYD{b0CTo08$C4Rc){gw2OlwRZv7r2xcf`7+%c5V-(h1v{AOk zvQ=eNR8un(WrZn~FD_I>9AeQJn3gUfjloNnsGS%OZ{{5 z)~hoP@KMTEAopqZ>&%B4^32Qn^+R@_KS~(u3Mbl`o{w9dJaPBtyQ>r*w=+I$)Hi7j zNEOyeQ0u>b_7gpBUU!f?5U5OKo9dq<2wz;B6(u{NDo2nwkQ5Ywy1cWz=uY9Ge))qy zv;jdvG>HYq18+PO#u&WOf_&+qK^QjDC%F0>NB`Qa^xt%v@+~;j?N`F3| z9j{?KKKoOg?dyDKPBzh7$0N6_F?ipRsxywhrrYbWKE%*^gY6@*AcIvlz}& zp%!8_qzlo*6gZlSLfRZkAzN&$C1}Q$MOmXz#4`-Sn?=3bwnK3k};1ao2`X^=3Ug%jvz)vFLGuh!Mt} zZH8UPTRJwF%ED_F6A}wG#}L*P-cyKHBOxaeEDT5-c@(uCoPul9ILgktq$`(sp@yR+x^=g%8HnzRJ>)1|{ z9Wf?^lAMx~q%nD9TeQ@iZL+GQHfCj3V{~bvz{0R>p&Y~xbcl)0Ommm*WPk^kFJCV? zv&>1-oC!BsiK)cIIcdgfO*t{sc$g;{%%&t#*Ka2{anw5H4MM6MKzOzXNMM=DnL?q= z=2je6tm+RmI(lD{PQJ`a_i4IYR{j8zuc{}}5diW|#{JHw?9=&v$JcxB zwx&I|AoW8*1mGOHX+-d23Q@)-QrTz9Z_GRKjx7-mB_p>3N z$V5Z(kwf$QQ0gRO-(#6t+67E!(Kh+BjK_)JUZJt>pF%xclXVMfWSqx zn9LNTcFVQ^R3@>T)*6VA*1-ZQ@vM-wt5*wU7*P;eQmq`A7;{YO^A--_h@EVvu-jz4 zQl!MFlTD*;QLScH1`s9Kx-bqJJfIm@l8UzK>&djI8ZMI%wl_;cSuKrlI5;X?TTmIG z_n6>qB!G0|h=*2Dk2rD|Lfwi)O$cw;_P+l)^m!AkiopFM5S8X`{MU9b<6+64_S8Ps z@V^nDK!p3h>^0rbd-m5aA^!LB7oGKym?=lqvHW`fuFVc!u}^q)#q)v=jaWRpSyoz z?LUpi!^`=x_k4Roz4;O}AT1z(DDp5&(N&$nUv*+vz(=XrtycFz_RI-pg)VW+!q>m-3Eu=wZ4^7B3i zT5+bmj8yUlkP%?(&njrp6=x)ZVa^`HAU55zcN(y_ z6UWD%@a-S#;s3HOM`mus|J;zod&CZLHaLVj(@4xVWH2WW1o@RBD6hvuygm>nz(ipb zBh+znkoaP!y@;N0?J1HbpaWf&(ceoB7d$?9K7q1+{qF%l zJ4%R@lk0Z-dmB|~F_15%_ofruZo3iO@cJsEc8tV6V%xfqf2ktR=B@waYZeIg_TiMEZT z8*gp-J$&gms3*rC6!04Nmw*eNqOzLWz+-Z0#sqI+nA=OoJ}x|+rXASWWP(W+G>Hd? zfsh@qpEytVD~YAf{y>3f#1E%`=r00w2v&4@|I0X&^zDicgnD2 zG2T~b9)Yaj!sjka1Y}ZV@uL_hD=cbi&*zgAQ5Gn;3>2!3Dxc+;-|2L*&6kjugWEx} z@+!k#Ar4aPP%v{blNnn?qC{At!l1INB1pyJuLwm*Mk@*xx0YT@|BqmHYl6W#Y*bSl zX+%)!^D4DYDC-M3X)25xIPy^?l|nH}N>qs=5~A8d5Vnxy0xU#$Ih5zeYuRm)?wcHB z)v)6M>QkWg7Fp<6{9uI5%MFoigaEoF))9swRB9+m}Qs|gqZTX!m3 zc@o61me!mqkmLw~1mJ=pSCl6$0^8cmCPFHZQVOh$PHKlLFpOj(ylG{MG<2~E00MBe zn;d3kGT6CkD>T+o5w|5*L=@U9Te+E6yZi5KadrWt63s)UAU>ixw$X+l82N z&P+{}YMeA&xvNq=Th2~NB%H|HDQzt}GUSRdG|AJEj+BcEi&RumXu(RvN{bc+Z788y zC9MRa*_nfpsZ?!a5+KG^5we!sV%2P>Fu?^?N<|{06jl*=l*JEbVLM*LE3}B*ykmEN)!}m%0 z{{O{k{cdjO+i?_sFX_;4$JP0wrtc^6Bsw5$<5)k3KM|CaRS`tQS(q;)ocIH<(5y-r zY$5X~c$yESnuIouc^Pm=KJ~C5?;=ouQ$YTRzh&re#B#QH4-%CWK{L-_#WsVaAEXJ> zTfT=e>BspYXem4T)iE^DPnw>PF@k(-M?Se-w%?~F5%DD=lps(ePl@pZ?FP`P>lBrd zg%Lmp87Jj|jMVx*t^TD0iS@f) z{JUfbO0$nUURt)w5f$`#gn0-aBij3W370JWey7E)?e1o6vp+iacF-H#l4CQ+iM$=~ zki4Aal1a)&iWygkd$GFNUfT12-8ioiWi2Zd6j-3Wu&{b_DbNm6C5#7c&A_PTEpf?G zkwPOA6vp9kF0>aM8s^~JWS$}>O*BO=1%ZfRj$4gfx9ijKK2kD=)1Q~^cl0@Lr!Ee6 zEc4m&c^o6;?0e75Jx)4M;rw+rHV4e1DH0((Aa-4V^E>q9eEZ!G#y9!Kzszj=%4*A| zYM+0{L;UYp$5_63$i}In;r|(jKBGR*u8&6~L|A0h!=Wpj1(OHQqJI&j(7wLkgC7TwH6R3qBcZJFM=aWb1+Y8=q*Q8c9Ay(&+Ua(hdB;JI{ z_pa|hG56Qf9(+^ZVlaZAd?zo-UrN5|v04$y?#OPQ!&4?B8iM6xcM)0B@pk4Q(Zyhy z?(czi)5KQ)nv=$z2X2_l=0mcTw@Zj}Yi)#L7zIpMR;wWExEjRDQ-_CixRyrYN+l;!WX;7K~u z8-zLD-lptzBqQC+9ic;xXp{WclnC?JyUX2dVOBtdIm9}dw5hC_mSLHbj8j=2S}G$_ zqnpgV@>6cPODn{&I@3LL9*>AgKF{`HSU{*$2f}c;V1eTyFnuvZ@U}6tBLU)r)Coe+ zAxa0Fz%ht~SWi2BCPRuKQejU7S<)6iw^YUpZGu`9N{d)5sM7zXztID5r9%6(Wbr1l zE;d>MpB9?ol7ilHlA>ConTj&XDvK3XZ;u?cjtN1=q5_loHH*&=&HlY-W@#xEfOg(q z9D}Tvl#DH~^=uJV<8lw$_x3skMHCc5BGj%`f^PzCC#(W>iV>j_fNF}W0+b3)l5y#P zj}UkCDPQ9`#B`pc4w8sPkw8!dMoA*V8h%HwwWrN-#}N8ZrsP#3?Xz;xz>efNi<-P( zTS-$W#3~|@#(u08LvRo~7J^>vQB)OV!3QWUt;>!~!qY636kJNhRNt;DQ$&Dv^~ z$JQ7dXtA1E6>}_#sgyCqQPXP4h%gp0)>T0i$#HV!t1Fpitra!jISbdYJNQDIj%AatH&MNkuFg}xWQYP zRx6E)s|8H7aH~m;M;j(tV955znb9M_`p zO9`7Lk&I+B6-ZZ}uOtIPLE=Fef{>F4yiOsVm^ic|mkMGSC1aHtE6Q9fp;Z-iC1wna zw=#;EWn64Hg+(2s%M4Y*l`AOJ6ctpm*PEuMF57U%(yR=(5~9kA7|0!@o9hCUbMJ`q z4qunE-Hze4b@gHQ_v!f$I3B7cg?3Kh!in_zz6U5ezDIp@%J(GH4W@T{&%=$he@^eh z7fS`0r^FpmBj=p3=S%=|`z_BuA61NG^b3uen0cph3>mny6Jkjs+X0_Ho~AeByvrGe zkCpacb;*@5q9Y{LbiA<(jOOed+ShqGqhS+d`te-RI`f!2>lH_L#ENt!t3=Zo^|SEUXFcs0D8^HO(rFfi!sw+ zuUl^V+D{Vs!vdd1x`_fkOW}73uvmK zh^(}zmzSAA`0CDe3+kKKeTm9SV8q)nj%1`Xjjshx0@Cqc!dWYhv5+qepvJoH2Quo` z0AE0$zpG-B2i*S;r@JS7g_XSdmKbnzieJo`@p})=%S}63vUHu-x^?a5?{D$uV5`?- zX<|L6nSEXz8D{b3ac&tBN?7|qq&)YiNA7#pF=Ed5LIm+NC_T3Z#9KAd?vbrNykQ#6 zq1|!U*U(p=g+BYK8A5rrAX!y6)}HGF#&zdS>FB4WSK8?}P9t6 z*7T1`76YxsuL3>YyRo4N8F=VBXK7QC`aF-B?+>MqN4)JBfowF7J#EcG+;nfT$k6cjUJhB3$ectz- z;_DxF@>m2{nCZ)p8fFvEcJ$hG-Vpuz%Sq3^?SjGk|W3wUKwa+_~oQRm^tT1S`{txKqeSeGYhie-?-D@u-T~e+g&Ix1E<3&hyOLeZ}b>d8diI&sfSq z?E(_QPh5WWr+7{;CrvEO{BHWZIzldK8!ex=OmWsW*j^(MS!*E*P9NdlO|{{?d679`(8>!3C@N3$d(=&F(AA6RxaV(+p=xsX>uV{T(QEVa9%0J)^*et%UmOO+PyKPCp*pwc(?g)07|- z-TZyuQub?o#P442ceY#mdK~YnT6CW6XHC;?LY&;jZ#iCZ!DFBuXXAaR0oK{9}sFzacTFEf`S_>}3YtKYgEu}&xNrK}f9JVFErubD06 z{@13;Rt0|@{B#-F^b2Xyu_Cm_9`(^W_O`GnU3Yr+()N&2v4pd|)+~5QL1rr*Nw$Ts z?^sTqZ*W|MXq~sYVSLT#v#<`NvbUssOn2SA-7+XTG^Lf5URg(5oSis8h7`1=84(+5 z!D|YVrL7x6qO7egu@xFEwWzSEMpUdy%B&W#N({8JC{mSJEFdhB2K;rG-gVk{*5f+7 z&hHe;b(c+V5SojTwp>TEv-<3B-Fc2oajgi2^oBkm%wx;~rw}UORMCitrR6vh&{3`6 z7K(Rk#CPc6%Uy1Am>U5-WwX8Ee|q?q%$M(`D%#`O&Pr}zfS*02K z?{{#1^wzS~hB)E4X59ZhiXD{>0e!8+_33o{u|4=Q1v~b0knQEu?wZ*=LGLCt89pVf z)nYW|n@WRbn~-dz3LjoK$Q*ZfO&Qrt_r2$GA<161cOLBeefGYe2TqFUY@G3qUq__t zAAL5{+#1sSZR@Psi% z({rSI=Xb8$PT0)wRY>FKXq*ooJr1;%1?A&qL8)aS3REnlibe_n1R#G@J|}-OUe5Z% zai`z)1V^LP#W$LBO1qZEUEUb%`0Y#(JCU_Z5uh6mc>?W zv2h|Gh#@IJ<&X+k0b>P(c@PSZ8mUlQ8*CI}uu!PAF>Oq+S}_GhSR*pqY!cW}a>`;+ zii#o(Ln(2&N-6@ZMH^^}4o$c)FqF_JJ!z&|vcZ%VRJO9q6sU0*EzGhY3Sq*iY!{jk zp`=a@GcrRk81R(@QHpCSA_#>rl60(@U?7kX z8G%ZeD1}HAVNpO)WgyCvjFC)r78r(z@v&m6ps++pD&r1moYfh;)Yu}5f+SGZES1H| zh^Y*mhMJ)w6uX*%aNIY zP*PJuaUqUI1`(80F%;7?1kFtp(MnZCQ60g70Z|bp6j3Z;h|G6%#G<5g5<1;RNTp^b zjL4{}6;ehCjZToD;v8^snB}E0rdd=)wi1Y;#|*P_?iUjqnTDzh1&S!Tm5UT9q9~GO zXeuBwOd*)8!o?KgRS^}aW2F&qDMJ&9Swsy+$|xu(DXa>}F6AaQSOr8uh@pv760ucL zfS{_W6iJRJGL;N<*vq#QHkgZ5D>%bLTT4L9uu;=j0ZmC*#E6*6$S9(A*tVsVnH7b$_ZSi#y; zZdwtkOJ=z;(xR1yAc$0j$`ahgP{yc&h9xbEQ3!E^%DJtIQtKwtW1ArW<;<|61h9An z4iONnLxBry?zS=JmUPy;Mkgc7Fy+S>u&&gnEf$Qjsh*_Vp_2n+EkgDn3WQ%nM^D)tv1O$lxsheYqKXQ! z64=~s70MKn00dPPH_<0?`_MY)+$<5Hxwp;eVD!qR5h41yp`w$Up>Qwl69 zvQ9FxYSNoHNrvaNk!V=~nR zSfUn`t7@wnmNl`dV%05zjit8RDylO{S&4yBK@B;M;G7qZd8i+MNS6e8K1OYsX^h&f zHr%PS&DqIzcLGx|jCr%w0GIu4L8LpuO)qXoZTj-X9}@i>(&6jjLjknU8S#5Mg^#h;gdp!T@>OfT3_ zC4&XR(Z3=66E~I|m~MEf&9LM_qa<=bimHexgviJOg{+DUEF>aIT4~7&T1=HPLoA$> z$a0_zq=Cs=6XYoJIS5b=Kx7#i8BsG0B-TI~2xA6AkhGOZJcQ&YATkMn$Xu3?DH;rv za!y0Z3P_YFB0_+rC>}uqs|7|W2P%^p35i-`Niv5xJcQxQ9E2$N(_kx^7Eg~Wn^ zjojePlI@C2VOc21DNaQK&=drnAn2kqRyW7df9pHJ(| zhAo(2uvJIeOv?T5GV5l1X`}DS0E5BU__{w+GL?`i29-vQj{mf0z9?{+E9z&yR;L6H`k=M9jWnKB5+U=UN6e+_2+Q9{b7jlnt-woR46D zj9C#_qeV0{;VqccNtuI9OcV&KBDH}~D*>20T#L?d)>9Ozc=LgWcAxQiDXHd1& zjSJVu*L^uJePX#N`+z7KnF^{1hzJ6jDa#-#Bw`kjqLPp(po$<$NF*9UoQ9VsBxy=o z8leMVX)+oTs(_-YNQQ=$il$mXX-*&l)wHM)MUaap(Ul-((gsMHfeemHAC;;laTj7J zi5`HJ>Y|w^mh?u0oJ7-1R5X)N9pSR#{0Isi~!Z~-n^L()t$Qe z?)&FW=f=7u`>mawr(SjMXLo#ecFLiT4zX~~JjoNQG2Lu43S*t#`gwLxd+dl{1FRrb9n|P{mOQvLJ|vfH47^O<~L^s45v`ElR_N*kIwvABAKC z1BBcukgdU>!x$_EVU9x^oK46q)>!2%7$}TLt67+qjILl(7||3|jE&3&E>Tk~D+ET3 zTP+n^TV-guhD>GJRJu^q5gOW(wmXuDxo~3zFtkL4%O)}`9JED-H4Lo^%V{YKL6xI& zjWL)6AP}UMR!|x;3u^)^B~awW+c0&bDisV(U}oD|EEZQB5E*TR(1kZ~EXl3|nF=Cd zTA1b9Yqg6J7175S!$@dkGdhe3X(^>Om_*op0+Nv;3RNhoq-X)53@Zz}gT`|(xiL#v zTaGgZh_H=lg$UYgL5;BJa-(u!hp3mF!vNrg#cJ+mT`Uh+6bL9n8Xy&i7*x!N`ekZ1 zixpTPi#xPMi)m>iNb(fojEJ?C6ygcS2gBka*g_I$cM#eDv?x|VURx5p#U?RQ(8AS> zr85d#!v-L^S{SHgWXvWxh$-DOD*}l{_Z=3f_Ve{lgg+CwN7gwhx_RIjo^a;_wSl7# zPP#knX?(%rO~nCwVlOCokk+zPg+o#(Pranp0*eXtwg-GDV)ZCdEw$QVkVFtcL18{w z^!|_cJj?L>U;O%FK(L6^UzMRV^wE5%n6d;{dD! zuF}9`+0`fgvTr10=M2~Ut-w3~eZ#AMEUDED%`$9WQoNxGQi78+0iZfYOoRmC)*dA3lhK}&qqmUt zzOxvfeFd-iUv9DpizYnu z%uF|`2YX@ZIM0MSOf4!(-c^|6TEmDCLEt~Mr3}%O{Z;Bmgm^++7wrWSmn6Y2o94US zxgVToN?LWpFmWM5Xcx1eDY!_HmH8p|Jo?JN30i7i6+9ZxBE69Pzi>H@P!LVu% zIPWNQKmxDBd+w97YKW4vZ0OGsRtpX?q17x#Dd!H89z!ygUYXMhobW=Qbkv8Yr9~o& z12)dPvG30?=w>l&>kW<qf(*Afg4HA z2$)J%iGf1y$&nDtsVs>#NH?P`eN$KEy>qlK`dS&sULdh#^UtS!tHe(y5D^F>(y2n{ zWp%~TL>#yQFr~wuo#~kLoh55J^5GZPeBEmGeY|0wjpfbh#D*{oU@QfIC<4h?3IL=v zlNBKQvqnH;J##wPT;_ZaX~qVmxZ{fDFRRzJeV-a}K0Be)Ie_6eMyT;hLICVMR#5Y? zFmzHN4KR6FLLB5C3J|1=527$1L2E^3K(R-sf)GJzkV;!3O%l80i99?wW)kPUh}5ge z?(zuglffB)5yL(h9^`kTKy3|SqrwRRd`hpkUt3;va;XIYLx;>Lrx-)XfyDH2ojadY zxxYQ{dg>!8zd1>af#al&-q9t;*APFFMDh2T&%?R4bLZ@Gg#9OYM&E3}>CdE7R55xT zCu@|8^AjvTQ+FJ`K#IR*uV-*QJUG*U<7LUF-32|)#X1oS@E}jyLMDtGNRmh(^ZzO| zJ>c)4-r^-!kgp=g%MODTXPz!68(x3ZK6>?D{hYr3zdpKeSL1F^S;L#3p~L;zMHnaw zK{RNX$X|2GgXY?afriKsLM8_NmLv(wgCr~{c(jlGL`j8=;DG{Y*ODTYE*-M~*Hv`g z)G-tJJz3tmelwzd!|d25e1LjK6Rh}r&-35rpgu#Xz7c7{lcyjmhL$CDqoJG6a!$My zS&RM6kMz*uzllO%^Z9f<4}aId4E|w{CE}eVexHf;HT(UoF_avode6+ertOBJ>A^qY zoY$0l&2eNum+Vvdp8$G5F~kuCRH$|_AqoI0Sz;<7`DGD61ql**K<@!YOZb)A8YHGD zLWAZugylO-K*Yf#NfJQRH0ulnp=qSy(s&P({;vV}-N~XKLldeUiP6tQ5AD(Q%Oe#| z=P)@zMNJVT(JM*+Pr`mrO!!ZH38ueUrsdzj9LE6zkr0`bq6-dX&L$3f&TQO%@H-D* zSppxr0>}rkikSmKEiF(WPz3`@1tQX=4GL1wG}8YN2}tS(l5$5N0)S{JRG{Q4j#}^`|2U zGhVkq574PFz{0=kgY@6C>5p!z1Vavo{@3#F=$BBUm7u+Jg~dMi zB~FMJ^M3vPIUM~ce&4qJ`!#rfw%2zH*Zr_Hd2bFnrqe9`=q<4XS__wIF#m69CG1K6 z$+dP1{;BVTFT7^1M^NeKceZn-2|UG-1|?hyxrj;3%;6l<*M5)OD3G+VQb|NE9CzQd ztF4)yuYS*|aK;~h$)Iff4$gm&ULU+P$Qgm?{CWY;@^8<>;Q63+$tXaeq5Uko?DDt) z_`3S|N8HkSG_OGFuX!IK`$A%{2Q7RmB|#BJ*sCbS6_5`C=IwsyLs8qD%YitHI3T2p zAXF@3$yEE&QwkG>EQRQ|Y{#zlUb@PYrN-t{Cgv10@SpTy6c$K-ssZ)nv9$y3>W^b6 zhw#=cQf*+07uB3`j5vi=S;3|<%q1%W6B4+h($)!#4P?d>Y|Nsf$l6dv<4zlhicwL5 zqK_;ogHbWe2}D&yMJ%lsjZupRyt#6i%y3gKv-bS(GyYyEL-_~Czz6X?I|5Lt_xl~i z1vJq^w?KCR?Azew@4CNZ=&N!Ldn-s2DNO+FtDkAIN&Z3tAqtFE(rH)XlkNCH(_k4dp6vvJ-kIB85r}r(7Z>kzOjR(~sXp5GZaYn=u4oh3ntuuYo zw;J^8SfY;FuoYt-TBzahLLgZL!u~)SYySU}Ed4d?scPZ z+F+u{i4NWpE7hV1ugKB7YS^eykQwx+(!&*zl2Acz>wi4D@08vBsZ}4Z>5~Qt^lzqA zABzexRkcfRrvng)uZw2+4XjFJuZgrAZezHTd|BJq6{k*Jw)LP#RBEhimcreU$Qb>j-o7ebn zQ{lr=%M1r+h1ZuCzb|YM;_L*wxOd`pFysEL;EiGZK&AB2^XulGxO_Ndn?tvBH~O+K+)nLVUS40*qk`IN|i?2+FeWDS-U)E4fw+T;VH{m#h(FR zPjjfOKY2x7|G!Otm9?*19r|x(rMp@Gk+bZ7=$(kIq_(LHCujEmsUe}~C)es7FX>Oq z{P|@4|D`|jaB39}k5n?+%)5n2uEW!FVP~Z8D#cTzfTvTUjLMdT*7>B}dPeag%XPM# z;RM;GsaKYyHkJka?egQVt?zrXK9(}-tVAqUMRJ8z1t5wBP{|WTNu@%6yS-oHe5WFR zD%OnF{CE1O`>jp~_zCHfJp;mhiJ_sP4yo9PAgK1Ub1+ibL@N)Yf-+XN+e;K%MT#Y@ zN=~|`5Y<%>TuN7l6xe(V{UHi}>H2;Sdw%{efY+&d-e+xq_m${v@0Ww!Q~M!WR6A3@ zwH1EsD9!xbI5gEuM|?3RDJCd==a3KgN0AWVF)+_B!`pC8wI|FJB?@Wa58tlA<7`qT zf8iK=iUQSy;(l`!4M$a%|fb- zP&m{$_qI-ruq~tu%Lu_y5fESB16<{l=Xrz{3&%l~1u#?__q{vn%N7S7*@*LQA>BB| zmoFcb>E8Rpw`QZJOox8+y{J8KrU}wqrp80sP3vixStp6J&{bs*MnBr!>mxGhLACyj z{4-TIFGGz(SPQABri{8)21@HeWUex^8Chqk=^?~4sn1y9QnZO#bl^Wysvp>=2>4%H z&&#v4Gdbz`ZI{VEs85prWVDplKw~0g9dX?89qKC}@#Ba!2SO?=BM&pF72wIj{z^yW z=JVv!)9ZUqXY#hu`NKAww&S6N#^38CfgO~{^@aT*&(i?NH3I{(HIL3byow0<$Dr~7 z*9iM;(sE>SyOitm4`KUsdiulDNBCh-vB5CQ81j@Z5BgWt;*!}{dJPTtkS*PoyNDi$u2nq zi3_gNk1p=!(48Ah8F6`m#;~;9W84GiU;!K{Iuh)km(|%819sTb4%9Z<1UstEE~+%=$XyId6J8TJ!F-Gmy88jE?hxL79U)sHrf9?r0tZ}f{Iq# zRZ%Q#4>0<<^UH`n`!Erd{yNe!7R$>93WF~zdHD0JEVIwB<00)jLsPa&z%winox77= zpHWl8!CdQz2kf9TrNcBoJ}kpo#Bzfy*{p+z6z=14#O-bEhMR|P{h0e7qyKOA-KY8A zV%`RTBqroP1SY1C*4~ADLGf=T+wSdNDs79kEl8PLIDlm+> zhv`4_ju&+8hf90i9NdZ`DN2hLGhAfJOBAbVY^tm=qZI}u3qKFb4a%aCMIs{+>6HI# zK@riM~@BDBB}|9P*tBqQWXJ3Q;fiZ%L^qUN{OhyI!7mK z7@=Ne<19rF+MhW!FoJ#3zR=ae_iGeU6^>G|7%En%#T11$*ySAGTvN-shC+Rva_w#p znF~k|vqF8x&$sIJfZS5;*uG52F8up^&!_g05q{77We6ewq4PqB08l)%3Rb~C=GX~~ zUY`GAqcbq0%_=z3B+vD!qV7DXR#{6+1SU80-coo^)(bO+GA#c_Q#1~AZ$GyW-Xj%z zw2EmAuqp`1Hy>LEpYDIh4%0r^d&c(JpS~D=DmLE=_D^p{stG7B6OBQjf&DaTDGnct z_2{M_&nbv&F&737W*Ei9F)mc}5}4x|*s8@ow~JcD3@Qkse;XQ115aAg8_3bkFKMCt zH@T;PY5G*CoRl}SY^Y97Jwf^9la6$hMTC-ywA+tW7w{~z)New@ycf5WF62NLlE zR40p*P&r9Vw&om8`I#ZxiPqT+F%ztq$uY+>32l4b^u6r$yf138w(^a)GE`8ZXs2Av zf1&+!&8(CDlb_qwlc4lwI573Hitp*8LBWSXH4FS^o&L7){Qhv0jOQCBu-jxRU*)(O z<-1!k#0=Xa?+zdPPJqrEdN3v(gH~7p%;Vj?=Pb@MS!nv(d*@wamt$o=LP4cH?d3mw z)_~p)Tm8hp*wJ^(9_=#lXDVbJj!!arq z7AlR}V1gqN;c6!~a0yM~rv5j$_tCOinVYND`HrYi$qZsREUETE;*JFWon43Rw!X^3e~0 zT+C=}a0((yh@_#3X(FhJg~3uQERu#IC=g(+8Y3-{wHPgEC16-fnJhrlRRl1U1rbhJ zVFnFRFhek8CPqMGA&i+;S}a6u1t=_pYNSw6V$p1hq$a9L3P%X0nrJ8ykwIX@6==zA zqghZ@TWnAPN~Nk?voOYD448ugL2DsmqLPdoN~~JTMwV1ECS{G zRViUovZN}YjjgN-3ju>mkQlHzBS7Gl5j4;eOhFbv;WUjPP~_7?P*_p6v4|jwK~Yet z0w`2wk)g`N5Qr*-r6?#xDj{h;A10Y16y}FdNbh7F$bYGvKfvSR?ivabhNz+oQL2J@ zVc$aJv{exW6qpncV=hxlR;0gGnR3bqIJBqXpWWU6d>`PZrvx}4R~nE$-C&}ByFp0B zB8?UL?o?OymlDd5$(lW2%5p6kMT(0YOmZ@{l|-dhgGwz1EMhEBQnIy{CSu&i!QgTQ zSjN_oMMXvg|E_Vb2t!#)ix35o6NR>ltYF8GC9fHc7AS%u!5nHaagnvESyI|eVNh7* zSzDN9BHF^DiHb(vSKRmZ<(;PfKbZf$X4dnRhh=P(=I3WpY}QhB!L8WM&HcOmJ9&Ca z7N&V?1LNjOP@xJ`I-}hPPOy6ZFxpdFL6oMVl7!}FC~1vJ$B%3oPcA;GE>it3&rRRg zeZHUN>$A2f#n!5<@st%8;U&tgvQ}F~*WWXmC=Iq2oN!2}2!*PQ3LvVZX;_O9Sb~D7 zP^lHPloX72-eVQoRS;GuuJGNm+N_0Opem`TD(eRXsGsQ9#jwyGVTo22*0M^bSYMg}to5Nm9JW5%MI#XFHzrSxj(L81^v?EqFowky z@u~`2Dxq2^rLvWx#6>ZzVr3}WRxN0ZVxv-sKjE<-DOHS-NWoP;EflC#ndwiQ(Gw<= z#Srhi3{e$95^~qioy`^~v!pdJWIIYV;f=PHUob2W9P8(Ka?N{G%3t*67)?)`OBlc5 z*BlSJ%Q1M~JP|gwV%#^0`IqP8B7cA9JNs_)96F5A>IBoH@VVd5&EHkj?jhb?H;*~z ztnG(tDOCmZGNDeJ^*&)fm;tPaFX3&91F(U|iZK3D8vyV2{`34>AEm!*H9z6qZ{F@a z!y^6aGZQU17ELCmIV1subN(loV$~3(2pCkI9Py(H>)P~SP+s(#mmP+`UQ*^AQ*)hC z>8IjIN|I0aBtB2FC{Fl~cfXIHe)I5ARvBf+!H?)D!40AMG9w~_Fj+TkTWHaxscl%s zh^avkKX*jbH6=tt^`C@(ts=j*l`9Iz_@QY_{h9(I(v&_sK3_>}Zv(J@JvaH}pfV4x zV!WP0Wb?WeGi=nv4P>^$s~oo7NDf*sG2Ngjf7zU7BgR>*iYTD5LyXh~kSh#kq~Z~_ zDd~nV#)&Ewg8u3IL(h3cL@gA{LnVx%m}jM=Jp^#eUZb0i#trpk*F*QPI?9;V7=<_D zfszKc{OPy-uqg85f`u&wwWg~t?ayC})8BTy&i3<#8J1e)Qe*zj?RKQ^PTt4Ant{TM z4BT>4%spP~JW6yz96!{(2N5M>1DExj`1&}o@@sb8}p;om3!BDNwjInZ=lYT2M4#E{c&08=Y`W+ereC# zs-Bs6xla?s*VkFsnaGL9U3COa<{oLp!)a9Mm6(X$oit4`5qVBHMJ7$z!uohD;v9Nm zsn%hN1{1B&v0z-Ta#B;o6rm%W0Cy-}b^?SCnZWoo@_Y!K@}@{OA*`61;F+jIgCOx$ z00?zRaE$k5!HZ@7HG#?pk!M~%gD>O6l#v+HUSnQ$p+AFHqKnnldfQ#us~jk1mSej0 zJ&Z*nAL^8Tr1?52>wV^KUtd2^;%jfokUkvBQLiQBqN@%|jwB$7`6tix`KDR#m%Yap z+}+{-pZNbiv;08llRZyzdFQ)IWv4}#^|D^9*973g%*vS8yF0f)XA_wAf2NiM`@e@d zHhBl>TUC`UPU(0X8kwEa%yk%!9LPu#!nAZo-T$VN&(Cs~~V?@YiZjZuU`sX~<;iy-l*ZVm!Xu|drX zp#)4!305g_8M}#)j~v$xDvYWZX3wDF1uQsVw^`n&#Oq%ZKkMh`-?n~b5h>Xq@$wLt zCvF{FE>;=ZCFLk=EKS>;&Gg*#@C)xov0lFy<#(UG--^BL_419VjW0)j^qru8s(*A2 z_QupNZ9Rs_2C-rN-DOONc7zfgTP#RFOgG_iIwtOouU?T9S6L)6fp6+vX-4^mFHPW~!2B}rfPVQ{jbD5Yh#OBsd*U5n=EpOFO$ zP*YTttql|_NkDxZ$?c}$#o<5BK#@;5>4^1wcS@`G(+=mO-+)Se{*;~aYFuCZk@Q6o zGJxp@b4NGl&RcJT&KeDZA_KT!;?~g}o~MDVhr=GEJZYTGfC3_(Lpu&~2dEn31Mts; z6z4wV$TpL%xz^8LMfdNnNek@oH+$y%wH1}0279nNJZuES_b@tT9*N&@f#kriuS@gu zVtbpJu75sz-P-@iJi$$q&Ag2;0{A6=O9v>O8BhQqIi<5`V;`aFV_98Vxx_YU!E9EIh}xfly(@Hsc59G zyz??e3|MNrSdj;yj&C}XQj`(VA2>vC9&q7%Gpx&NYrXpN_lf^>p5r0%&p~O>!=OJC zlEKs#;(e3;i703Sig_uM5|L9(K+sWCB@H2hALpiZNe-2P4_D8pEA_(lN{DhnI-xWnSpwn!Y))nh_Z zl_NfsQjwqdvlkMmAvH$Iey$k_#t`T`MOUHio+nVdOGpIvnZ#z%G{o6`?QOZ)vXZ`^aO5v87>BT6lcPe0iK#x1)dHu>_8Cxu8%PrO0*5VlU8AJYL9Ne` zZ-ay|@~`&CTo@Zb=sU$BfPqOK$ZmKOdG+6Tql=MAmdceHP=!U9$l>nR8DW-U3{mW~ ztcN~L`WB$b+acmeFYlR}DoEvnO2Wp)LCR7Jrdi(Et6Do^m5~*S<3UAOj97CiiHL01 zNSrD#h1vrw8w5#~>J6lr37J52GRzG^C|7rPH*Q6`1vUaJ47WD_lP1CCvx#FQL{TzY zqAP7BwPaC5La^p*Y7N&DN>EMA46v$^W{NoB4htsQ%0d_pN{=K-s+Q=|66+}|<{(#q z1nsz;V;I@FlIIcTVC4)Hyqw(?c}?X5Q7t8OiAazF0Vz9Xy%9JpM6inn)ooQu7MX4; zfU}gb7_mk`;cqV8FGH^&My0e0^^Kz}H~|P8RzT%i$UNgz-wj4zsGR0FZ zH;l$pDVFDRG^Z^&NF0lKE|r>YMOAoTmrpR(jP~xhw zIFgpE79gRT@fh*UWC=)|lw|{t2b8jSrUGI{L#&P+#@5orPFPu%(@L0AnRH<|lxA>M zyi6P=vD^yWl2dlU8M|^JP)$l=O3RhBpe=&7)J3#1iv>nONJIyWNX}uGwV>S0cG(w& zNUp2->zn2~Jw)S%?f+6c~piR8rEnreTX(2*}%Yp@~{qm@$!FTM&yRIHv%N ztY|!}Nk%Ee4mi}LM~vP}9R)fTX?3cIHA*G1Dq;w8GAX-bd77B+QKgcwc+fPpZIqIU0%&kE#DN&<3IwCT9aBum zrm&(I6)M!OIABz)RN%?VQt&whSrBRxBrg*-7zY`yLESCN$~0kR8cS_OwIgJur4w^- zF*#)iY~~g@n#~yFY?cr#;RwaCl%OyUOvGLbTA@-bD7cN;X{?&qjj)uo7fTfw?zbG= zAx9d^9ZXe2ZlZvXIF4M(06`fEWO*JnZ#a;JRDlzV!$R_cGGd%kJ2Y-Usm*D$u;ik- zWK2mNovp#ipeaf~C}{+sr9zQVfK=61FG`hy%+oLwF_GJAVuH&OR~3z+CbfZ?Llsd> zVVRgk1uGzJZkY2S#%4=nP=^85hKNMvIVlot0VN$|ZgqG<3e${66^yGI#>S~woD9s} zBAq5Ac+u4?qN~9yK+U;nxr>E(%*w@1Qj0hyu+`Z|DRCPd%$d1>prGy71X+wK%p+?^ z2*aF6uB?o#NCn`F7|JQ77Z8r=nAeu?8-l8g1#fhj10#ZriOmb13ra;9zyU_sYZTT7 zATF|-xJ$HMWe~;^pyNdWO-Y+zZF3>HLgrvB3~LITmM+=?irTX6F4@v?6msh!jpdtY zZdeP93_}}i)euoljI7&bB5R7xNmRF8(tu%Y%TT*PK*TYV8L6ANYed%COSCI1f(Grb z%8jA5b(qL%$pxHA5fWO3DGRBPlM?}JR%1bfX;T@4A%&xC6f-jnL{-~}W;>ET!FkEWZs0w8(7Pbh(DPV#MWvIkO%TWaZL`zn-g{feTNl}bh6=kI&tfg#C z!VKgWX^n{617!>WMnxc2K&0id%ZZ9BiJ4X^ivepPSOUN<*kdNzMJov`%Y%bNKgFYq z%ZRaWH*^sdGRqcAg}`eX%VZeA9x4o2LLM!O_VzgbcY7h)mmC>`DTJY&Q!uTiRY|W* z!R8ea#}XX1wzMnGl(3y;lsaB3WM?8yMj^_!TX|Qvf5+L}5(@*G^1`-O#zGK2v+amc z<2uS(+8oWXbWXdS)>$WW9}eqFDS7yDAzif%0n)VPv7A^82DVr>%Z0}>9W+-emIBR5 z4YYu`g2)!O)+F5Ac)Wv)D5!IcsU@`w8_N!HxASgMiw=wfF*_5ga+s|a4cg^`s;)D6 zxFW$pMBxY$bYjF*!_~3Pj+Sj`5S=ZEO3>kwQd$wrQKL&6Tws-<2p>FTpyG1;#~Q@2e6}*> z7j3qM4FXzR>dh46rH92u~%7iT< zhx%=_up1M)Ic2nTj&X6Vu?}RAM6#9(%1v%&V$|DNosDwC6I--MzzCpo?dl(VpR1q6 z)Yo};LeK4#H~v)^$lS+Yi3&&lFdK(MqhFjeL$7JWv^&jG{`fMYy{Uod|18xBN|_Z| zRVZOFLqQeDD)(Im(uU*K1M>wO9F4=bJqfYXyyb!E@9Jy;cO>k%77{=td$o%6J?GV? z8J~T`u?_IAb~9oHJBuham(5~C{GkU@$&r)FWlD!Qb*rQW&SxHzg*rVS=NKG$H|m-9 z{_I8}^`W1oHG+Y-g4DTG9k_me+MlWiN!i2J!?3Slo{E*7a|{qi=RW)v$Sw* zjq&R=Jr6ChtR=%OM#~Dou?o;BO3U~p?UT3~4(|HU8lvSQ;jgNR-bDzax#F%0~IX_!X>yu5%yG& z<_9x+hz}4QPARG=y7S~d{eHi0r(usidJNIupoYkvZaAYQ>u^ETpI&~+oxB^5V|4XI zIy`kbpJFU9O!wjQxe3tL83ai~45o@2f<+@nK*1`FRH&I@Dw%4cmf0%S&=sfR5@9ek zi(zaHg-)?Q7w_e_o_ZkY(0rcfzq{>nJdQ`1uWfsnRkxCV3@M^~)TIy3`~EE8r!~up zg4pSX7gYAzlZ|JjzWUa%n%$mXMZPfk(UC1ao(J7gIUAiY>1FL0+;+^{MVt5o$nt>m zdK_Wrxy!-Z-)%addCbhbRGqiQHjg=SPbE6MTwW;co@vew9eMse4gLd#C??hec;CZW z^XHD5AXEwTE(FtA&7^|?Ll|js2>^wYF%oj9$^THSf&&60G8mQ48vz;>;&mNNb7`4D zXXM~}6TVSl$J^N`I{y}Hj^|%E^GzA+mJ^wk8SlTpcjNDe#_tga)34_Qw#VL| zC(7OXf$#}_uj`P^Vtkg}H-6WfV)P07**$ycDeo$%s35ALZ4nldredsl;fQZhnNP*MdH z1uVlrQsM#MgWvDId+xY+*9d*crXm^`suF#AbM9bxoDBSAQ5Yw^%|7yD>js`&i|Zq| z7&JR?LGk8A@5y*PzdX;@4M7`xbLH09A59_QK>Ny(4G9HGr5J7y8;kKB2~rMITSK~nxpIcY%ACE7~X-xgD?&cY{T-aW%`IC(LFze|^7|*UJ$!dq*(D`O2V>5Kk zvbNH;*ZEvuR3^M+AK!g%7tk+h(iH~#Fs;>@wcVpdSqWWa!JdpNiVN2w<0dy8dGt=` zA3*qOh%X5f;Kk8~U){g_bFBqL}JtSXMi8 z1ZuzzCoOIqxB|tHjbu(llJkXi;_Cw(n1qFZ@EM8(q)sf>%14kC3oS-b;~6cCBM?N7 zGUO1YDy2jiTSD5XDpjS%#1+EUB^ca}7>dLML_sQ1=P|iWG@-^aibL&_9ggoF)B zgJ{Ci6$2PtrDKthWxx(j5@4JYq!dLad4BUKpApenYhMpmjQD5%I|ShzWq zOe)k`g_f$;L~|^~MlNJzCN~h73{(`OY_k>&7+Vo*3NKcwHj*Mj<6ySrxV14BQIv8L zvPx!HFs6n93?Zb6a)m7@#!#)fD@z;{td?77V=jnPGT5b<5@dvfB+Ionu_g1`%jYsZK?wFK zWDY^D7DZ4q%7a-hc(60i(WpY?mdYq19RVAHw0w~OnHR1|-|#C}KnFs*ue!;w9R z%?Mqqo!Mg)5w}@1c@(PPQIm)p{YU-XzY@<7v$TZ}NS#?9>NzXiB1jTH3yySpo<}_K zFEbR2Jm~`>e4lcKryK*?j>-1EY>MuD&*85}2elBs5E-IaZMBcBz9tLHFc)xg_I{>J4jsQH z&0l)E?p$S{LiP2qK|CUn`u#PY@N6f{&eX&4KhHO>==mHdY!^;H+`MJG^xZj|Z08RA zFda)rN#4kZ5s?0G-8>C%0%|z?`KpgTY9N(5dgi-!1g2mx^{pwXfUE1lVsX?hO<%E9 zeeN00OR|ji<3$w5f#<4CkHTDITyI=AE3?DlXN~J;O5RM(FxihZ+#6zPW-4L$@?(Pr znzzm#XQ!g=a7*2k&#tFUWO05JXQlz~*9Jm=i~9R3f_B}w?{)+qyvokL8pF{Tp6hQu z0E0k$zp1_Vx6E+hO%TPJ*}pBSmNpL)PF`D1Uk!UoBOc@2HMDYU;JMLbyl4CQl!uV@Ac54%rhIuejK^vLy#h=yk>2k4I`h)}!C)P(Q8fJF6 zM$?zPdD-WI-VMC*tSh0_^Xq<_Cp5@fh6mPY4BqrdCe~s)X5*KBixQ=r5Mmw08*uC` zC#*EXSV?1V9jP&H>kl^MyVwMxQ=K{bHTPw|e{t!~ZZTVWT=VwYEPJpo8X}0GEo!l; zXI*dGoIyIFQQOyhmebc%$Sf2_x{p-7d+wNb!bKps4rFkTcK#$m(p5xiW1FB^-?8!a z-p>C&f43(FN1pL(6g$&1?P~CP!ow^!Y~#;BUIY=Iah!Cn1lywJn0EpO7+WM6;s6}} zbh3IL{?^g;8)v`Sf6k=-S>96PpQ6tdAq6COX$1?*K6!lQ&DqywFS8sr;hMt-S77Cx zp_n_c5a+0!kxf;$^RF7Y+YP$Lvxmk84dTH0EC8OkeD{a7%aKzv;yelB z;(;U?x#))Yi7sv&W1KknpuqWMH?EA=hI;&5`*qY~?D=q6f@Pmgro+fX!jBp1T>LYE z5>6yZJcUMM>V0$X;|$_4Yv>@*0SThx1S9;XfZR32$^g8W*mCKeu$nudFDDZGd?5F zm~+9xF~(kF$Z^zaLpIsv4tM+ee_}iMKen15-%I(Gks>6*NF$O2n+G5fXW|C#D$iDK z4|m16z+=s1pmJ=(3NKLkg3@`sbI?y*?^?vXFqvt0^LZKKP~ou`yY6M6c!uNC++8<0 z5zcQb+-m5K_=j#ox$&rmhB+;QyKb8AYB|(I)6lmBXn@Q)dkTT}Wlx9l;~}=J?fnyT z<8;I8_ro~e7~?GBEXk}$VA)ThIK1-!c{t!nLJvhey&j!Dw0q9keKh=OPCp63{dt4U zi=H)3^Yfo5cbnF%VRsB$XL<2~^Xl+=L-W~Sl021O>h^os8e&Kw4Epis&Z{Qb>2U%d zPB#{bpNNG`ZO9OKdBjl&(c&Nx`^XRCyO`?{`2QeZlA_lDWeKx4NFFj3rG)^5`5lUC zf=!{VXs#5#@?*Sku=GoGx*XGV{(=96BK~wZk9O84Xd&%QwXQp~$IANmv zoee-QYB`C>fbkCWJXAg<>kRI2Ta3Xq&%dr*^_`i0(&uXt7^x91wnn8y`ld5dCfa@u zur_Zn-?!Hx!Hw(h*CC3Fs?U8@UNxLgAA5e;)^wf6Bz`wD%J-m2X&fF9 zCXn!4V>P(D?O-5yP$U#osweb2zD*1_6Y>ci^J&MHoSSlR@cMJ!d`Rgw8+&^Q95-*a zaq*qJYVWREw_D--_i-ed5#_K$Vtq$i#}RX`HVm#uei96RuLsFh_p>BCy&b8rk?q8U z;Z(#}z8(Yv*A%_f8r>rC@X-g$xZ7^+a`q5CT=NIuPnTeqR~pL_=Ozpojp2rv2FPv; zR6gDbg{ew0 zLthgUn}LwxXLLVqQBEfq=71%e*0`d~wD&cs*-5@VN;ml`26{wHl2cyK@>-B8pP!IeN@ioTnej&vhc`imsW+z9u@@V}cE(e4UuEY&I`)Q;ogY#nzeH}21q|A{* z7ri^-7%vw%%DwY{1DdM6UcE$FowgFfPvbuK&aowjDc>!gFX^*1%MU@UAd-Br$tNTb zksvUSV8S9IlvmLi9A4f0sD;ac7>6bNu%PkG3xm?X5YpL%JjC%9duP1Rf;iKHPGQU0 zo37&`dLiMPh7CQlIKy)dvhHWzZ!k#*a6|FTx)0(!8Batr=gWW!|{3q39!(puID28qVqPL!?I-o-sb-9My6x=*FOjf6^Yn=3b{q7xe>!Mub zbQ`#g3pOt{vekua@b9(OSHBg3&*2Tnx9EYyvpCNYXB;q+FXmhf884QC1Ch~VY>>Eb zf+B9y0UKMU(?B6 zzg{m)VUMmq7f<7UQt>B9qzdk(IUf6l>L$Z09JP=PzTV;sx6bOG*k$$%@AF+qj zki#dBEo>d=y$GzZzUlCCy%>fhTYF*+i)^?J%Qz=$;jyl_4>-U%(^EBbTizej;BoKS z?zyk$h7tf07^VAWV^fdN-@bUQP?a8hcM`**Ek+MdE=eVhOLvLLqSIHXXtE*RnaF4A zFD>;h1<3b7iyZjjnMV%(72PxuIVHtJKghEuJ;%w~L`0~!QvRRW@fB4G+wU2liEpq7yB&K$=v5IP9gek{Vr8a!peUa z^>qi&UX;$_pRy=qz<;QTgfMe6kxn)aov`0CqrKgFF7$O{xf4%5Gk$f9 zo`wM4cd;2POJj&jm$$|l@^$s8BDcT|r@sd5p7bG)vh{p&;(6)yu6FHajwh}jgG~Kv zo+d_H;F9m=@H{gEuMQu>4oi_KcKk@_yF>bn%s4nE=S>>TTYRC`W{(DN_^>&L-D_E_ zNRZ@)K|4=+-_23*xQ6w%^KsL~7T~ug%ow!mc#YNFh|6Pr%xW&9eZ82oVBRvEhr08; zPB9MH4cl)FJ~SHo)}l?JUqisZW@?%nNE~e19CdF4yQwSor8EijoT)H#qOEh z^8%z(yp~>4n6Q@^4UIjXH=9hzs0Q}~I&LRiW?U7fY?*OX*u%^jhEb0TxUC2rL2|UC zMEvl3$Mqz!nBS?hZFw*~+{9J!L639uomDPc@X+XT%?R8W!*YGZD0XRwFCNc5a`Hy} z^&ZRv)o&2SPme880-@MPJ{3__hZJ;08S>Xm)m-aUUiH@4oiS!1jxsP@!})k}Q;fJc zd2P%RVY+Y~&dy+IdcU6dao%{$5q}rnCzx{mVEA|5G2X>-laaZ?_kiNN#5@+>UK@y; z4^$)eWZr#V`p#$1+vn9(9H||o4oq{s7nt2%Wpw#`bw!g^vU7Xh7_ocl`j(rEw)uY) zD9NsNuD#3{pTmYXx$ifw zu(y`$-TNh>M&VPhUzJ360re4^9J$gILiv63w=KSgj0IO6USGcVGwLDfLJs+p^=r`X z*twy*E=P|~FdDhXooEw#y~l}uEn%u3fopz9k^>e9{S4P3dG34A=n&*ked?tsY_2r$ z^HFg<=i(`dP2+SANdp_p1DJQ}k5fkBxavUB1I|PA^~7CB(?Fd<83^PdK%aT)Z~_w` zcAq65!gf0w5&Ldv@AitQtVHvJN3@`W41mMhZoByMSCHH<{F^%am_X*(!9804L(_*~ z#20YMK*kfVKa|gzSC-eLxBl1judCUQ)%h5`AI%b{d$|WQ?Q53 z^qj=K;dYxgRC-uMI0PkqA8GaPNe=Yb`QzTTkl!2_5jiHeU%Nbe!}pTg_YZG0F)%Xj z=T*edpc}GH)G#6OTK=?s294 zB|I$NJ>AZ}w`O-|Vtaae?cenNBo7b`jZ}bmd~Wxd+E+PqXw%Hj7fzh-V_kZ~j3(Q& zr0K?BC-YfdJ2#34$sLtkl2%2bN`CW_{E{di9((zjG!6&8tf*u?)4sod>HTSlCwobV zpt>9YoTOMombI%aR@*}iyo;28K;>J@N>zjo1xC&JP8&H|scgK#!7GynluXTsW(Jyw zs){Nimz{aXYmVn+?rj_f@8XNmC)p;zAi^)k@xt1sB|(U;YAZZHQg%&J~`aW_c6Cykb2n9(d zDOFHHr&ZDxE@gVc^zL&R4CeDX_1sj1^nqkMob;MbnbHay)e(osqJ`_Z5jq%3!-)F| zd6;0JVq;rwB*Z1Yw?NizQa2nbq^!lVnQRPW?#Sf3Y*n14gcQcPlrZv5VeN;?5)~vV zR45Rop#U#14l+ED8bE@Chm)|VjCkJKjGZ9^uUb5zZHV;Bi(V73?wigFx{iU5ujZkz z-6|L}@414+MAtuk#?OyO*^{#@n1rxB>HvrWfuJcykphLzVgdeNz@1bY{n{pu#m;@b z`vkYbG#&FOsM4Dr9b|aL&s=@=f$eJHQ>X7VQV$~eVA5^*3QiC^>jKcAix{;m6hfdD zNF-lAwS5k0b4Pr+m1*mvskwV`hZ!ZILyvk*y z1f)9qL6XqbDKFSBol`;{kcY_6Trl`#G?_R*m*>h-@RQ^}3V>=JA#~@uC~dyy7Jzh} z61c}$DO@w#Jqgl7#s2SJb2N^ww zLLUj;KC|6tSMGR(Kqcd;Ys2PZT_0O9?5V@RDW%~Iik=IBsQ%(dLJ46=#sm?xqCy?C zO&L)lBvJ{3L@PLGf5qK`S;CP%`@5LDyf&?t?;3t?JnGJ#IrrT?JD#vPJRa?vv76;T zl44N&rQ_lu{7}fNBI{y>NmQdXNTGaIomZ0(NU@4Y(EaSDy*tyKA@M!x+uO!|5iDgT#aQxvm}DYyVoYYF&U2jLT(ezAX~dLcnT0EuFALw+dr9|wA|%mJ*SX84fzaIg<}U(R zHYU-ON=1-ZvpkfBWk4~^s5LDus;O-=nXZ8S@}86WaMv4cJIm=p&s)sy^d?_Z4zhG| zy4=8Z3EQ@h839Q!;GfRy=Up4t=6zavq**=%5Z8GvK-D%m*5EEnf^xiOH`B8ET&JVSacB;pc$b{x(oK^QZz z-;TOHvhi8b3%51Y>Alq&@fdr5+P4a6#O$&I$u6IpsoF$IBkiDiOMfQ;f_ z>rV{*d-cEru|FuHhME8}M0Hg6h<&=iL%0?U--FtG+xh!ph2@=}t?z!95i_=yor=5xN>;B${g7p}80oenf_ah|dF-YQZ@=e?V=K$-J(HW-nLGiPA*!<3Me<> z+`6!|vm8^sGHb3Yw&q&mW1z$G^Sja!A5B#2mCF@eq{L1p=Sltiy20)Bm+shj{QPfv zqI<%*)XsW^%{lNu=6vPe+E)TX9IG#V<9&0i-nQX&*mbctVR7rcHUX-N1OAT!z*v2> z(W{W<%e`0$X)?6&6wF7ors4oWe2Xz$_HG~$g-1^+YnQbdmvdc}A_Ox7-J=$(BBFoR zPc*t05k7V<)g^SQ7T7Z!l>|hhP#}~IsOja%TG8pqYewUe_5JsR>^Y7e*TmU4e(>{e zy=?fK83qxlE;$ZdLxB`KcP3+%Jy?_@gl55=l@BDT$k~BAb6m7GVb@VXsf8*AnG$9} zv$na-JhCF7L`vov7**aiBwH$>MBs{31k=b7MX6JV!fx%r*Y^#03M0AptiO zJ*~MoJR&IIlqP9UfmF;_Hd(QqP&pb38dn<7L5rD{!_L=5WEwZ<#HX?9AEARpiRz}N zY4fe`hKr6&!#)!E=))7Hz9HMslGbMs2%}`Ax|akZ4)@+wSy=%-(>ZGnO0){OR8ir; zMi@x?YdcPI$nft_6l^rlI8T^|k{2wgFtLatYm!39ft^u6eaEOs5ikrz9-an74-O2~ z%ECM{Hq6ajsG;HX8G5qReskJyR2RPN`+D4av|of~w%MM$5|1eQ_?gYJSDD@e)?Ak9 zkdOc)Q?smf;ce$9D)r>2N7L+syVP!vu)GY0z^7)d@gCzPrr zjd78N&Jd)~CtZj_NhE-I_|MGt+#1?*yfh&G8DY=YWI6Ng_(j`1iwRRE3z?x%aKW>{ z_(0RaM++ciX^sI;>L~ZO9_D$vtA-D0Q6Ve#K?$Z-e zvOQcFajbbkUS3XLSqs&v%6d-B9S;lx#5pRsO7`mvL?(W7tnOglt_`5ti$UDem8psJ zK=BWhIqt)>r@p`l?+=@C;~1o-T*r5e3QBP=d>4#H;_~~DLrdd4>Llqt z&YWq(>Tf6;Bh^kyb%F~%lcyDOYiD9@44er-iFV7xbfE`hV9ZETS0w!q2>~bu0_SoC zNSjhg5!j48)!6BZK)7B^M+pTIhSpU{`tWQh6Y-7zYsSPx1Nj+b{<9h7JLjv+bVJ$| z98Y*-j`H+kV{e{H=~xiyfs!DT9+I@`MOvxw#jY6^6ujx2T@EFFRGK8Av?Sd`%n=8O zP(lq5qg4`Qk?|-9o^VA!6#O*PT{koz0}GdZH!+jhMyF5(C&kFwF#D5RY8YG$Fyadfh)S$$k-Jef@Wa zkHp--IQB1x(ZwNDnz6TXZQA59kVjg08>-eK_9coJj=9VDsukCjOeSo|D67{~1S?V| zj0~B!Ljo9jXBoXR{DvW@mt(E*%Oaa73f6 z&u$)dN08r&8LpU4{bn3hcMO~rlgM<;iV&3~bn!Sr()kiDcs&nGuo!?`JHFi9?7K4a zFtjTLBbO3sjj=nlIpG^dX;`0SSz{(oB_cMIQuh%C^sf7XS8imWg5V?!Adp4hg3>_U znFp(rmv=Iz?ksRM8G;cI4^INaUP^&*^L&U|9hbP;ljVX|ixUUwKHfjSqx`-1_x-!X z2e%6=?b$kW_&YSmxA0W&U_*iY`I)dfY`$TnT^s7eQ-zw1aVQ4MP}SU!MP>vMr=MwH zB$`X@`km{OgwZ6EFe*UzBB5jY+2h@pujPFp>Vjq^JeD-)$AW`3jq43=%t&|n_q#p* z2j8!FdqxmJxf2)pKwZfZ7Er$kLQr*75R^!vvSmd1A9;^6P8g|DCqYC-`7VY7tQOM#(BG(MY6bY0TcDo}DCK7XUB9ytAlNZeU zno?~uDx*f-O3G58TbXh7hu?wTJfYES8GFMH2~654Qo2l_fdLdD4Ivl--oxTk;^lh9 zQhgU3+g|%p3P`#bW|4g%KqXJa@kCC8m%zK}u=D<;mhJ!+D-Da<4Jv2Nr2O zi9WEHw--9FLpl;s1&NFrT+yqtW46RaXb8k_|N-9aGD zaS%dZ#I! z;#0qaxpsj?e6}tpyLoBXx)@`|1!0kW`okxz*KMKB+Izj>R~j2>sXIh0g~UN2JGc~3 zy|I&IA)6A7xtVyT$h^A-yA@tw%rkwMb+{Hls7Wo5I-s6LP|Jq!Z0U%q!0UZpA3pru z?;&#i-1BV76K=1$MDqys6Jl(bM38P5qZTR^DcV%k?WG#dTMuSk)4#j3?CP`US_(RN z`y;tgN#sbO?gU=Y*@@u8$}e z%-)wn1uR{g9vuLX>@Eb?7_P*DDH|b50UWT0kQT3ES6j@^0r548J+HZFQ_=$U!% zTY)nVRxZa**r|x++9H0Kxupol38XD$$))mO6B=ET6;Sk|sHli6p67CgNZ)jfGmw!! zim8^Pw-N%4QImh0n(iD;Ziqq++LC-hSyEsU4+vRpn64TG5Hlb|XQ;7ks$BASa^T({ zW7cHVlzAECh%hQ9q*almtcptHi-T7c2)G6c04V{~yAc`ZXAtW>TWjP^^Jz9Zr9PM1>vKXW62`)%BUMEH=107u zJ!|bnW_*bc+8)FOGoBcrb|%5aS!QRE&14WQ=UuZeDil2&hY$l(4*zMKtceLeH*jXtbqvqscR|6g*CM2U0tFR-Y z%xuGvn@KOGWL2$BZ>oLAa&FISjj=lSLaAlyrGESJE1RioKPDA;C`@H|V&kf9e0!4a zhSwb0xSW}jaVL>vBUc>{%9;mxZTh&7COtw{q6kpDF;M1&3=st1;zPZN4HPUw%3U^X zE5D^NGt@p2St6zf$MtjZ$6B12RDnR$Jc51Mp>(Y#4q{ZZ$!0hxh_fT4RxW9>tS*hZ zi>UUaw)`G?vGthzbGS{A&{7e7)kqiUp9ySE(1&4y>t02cY&6 zLl0&Q5o*tX^Xt?Iv5AxjqiAA{t?@~?k&`%j3A+L@Iu+sY@48L#Xw3syr)d-E}ZKkyA74F0*0n7&Gb=Zh1)K z^x%3tXF(|}Nbv8j$;cITo@In zhY}tclLMGgK}(ytH}J7HX3YC=e|+QbKlIlK-!IGx+LJRFIV0E+B0sv0{?Cx1D9?aE z;E+c+F@y34#PZXm{N{Gl=4aCAymzNnlB0~8IT-5{X~}W`GAoEO8H46iW~Fg4kwVXX zQn6N+UXO}?=dQ&+a%wriHjp6jwYvBeS+G8fWe;mP93%)l3B~}hQD6sKhrIygPQA|m zJJX1=Gnrw4ecJiSl3DFEl5gbv-N9mx&O5+5Hw_T0imD+ry+kwurRHaF zIk3fmA1}!YJPNi(VAKI7z*4lUM~O5MzV{eDG;Lg`fR6bwirOCIOIps>()+`-ILB?g z+|yBMZRkA!`)9N^d_8iQ=G6ei%S9;(P^q|@URBAI zc934PPPOE+YdT4W!D1}L+%`F!e*JuP-ziTpynPQ8%M3LB4suu%^zjqhoaWeVip76y zxHDt|BubxLOFk|g6Qev&{Ww_10+}d6P zQiCY|Gpx3*;p^wT)d6_`#;X7^8)S+DmP~S9Z(QDJZW{@W26tFMu%xkQZnv-}2ag!7 zlNc3{ShBS?-AGj!szZ(j97qAlipIl#v->yS*Ya7X*0Zzr#(IP=at{%4xoN>dMNTZU4?K3G`HB2rC8zZBAChp=UJhZuFrJxnmnyH8^#t_#M zbTUN?mIXFul%xsgAc!|}08AmzZY`TAz+$oi;+-wBdKnB<#N%D-2LSHobX%;bEKXat zKB*8F<+_Unn_YpgQ>6(?)< z_MkA_iuR^{8*+k07=YkG2Ua7UiUL^n{MWqzyKx{#X&AGl_Gp z)9K6{s+RJ==5GIQ+mb^~{Lwy=iAV!Vmy7|(5T&A;pbAu>U&dmE3IYR2rDSAeA&}4u zLQsQBQY%S91uH>Hl!#<0kgX!nB`Bw;1jqn30mupz$TAUVC_yL+QKeRz7LW>2N);#& zrAkz2S_BFZXcCc15pqE?0R!_082}dy0Wt(ALX-+<8bzQGas-F~awtH|#YTmp8X85R zI_Shuq~cc#jLwEBikwE!4nR^SN(MltMovjl3{+BOkv3RV<%|_Dv<6580hyLyCQUR0 zN`pwy1vCN_iAvB>O8Af(azN|OjtTBm+LnR$J$at~=7+9X!a`dHeVHsF0yj-4?o3*$ zEF>NPma(ywU1d4SBqoHGSn!7$>OQUQp{WtJ+eBC;9bK?RiE$sK99Hsys%v7xqH zvF5EzAeP64SwxPq*hsNZBdu^owK1xrOr}Cg)kzT+Dlr)q7OiZRHttc|7&(+sOtc!{ z;^G#TRErp*wJ~(J90x3x6~szRysQ>oG`bax3TbI#M5_wOO2ZhubF+XmC~mlOu@hwq zXa((+$Y~k?Squ&0*j7YhFnKJj&>(S!N@O5XgFw<0D?!VPj~SFvGhimtWEK!)4l)yC z4*T_rISO%9iUNdCpaOsbfN2tws&smD<%Dze;5iuzj&PS{Z}VdJ)!j{J{eP4_~Cc?%=&M!pPSB}y~oZHq0R3sho#<<44h_iFr_mC z7^H5TNjEh3<@!>6*r8ZWW@T7O#u&~dn08sgBg;`y4tzBDrDtUAlKr{Y&-43pr=|~o z!X3W;kjF4=8C^0}Dy)Q5RqRPpVydQu1s|01MHN$XYl6u<0*Mj|AFGl*q2huq7_8+)aFb2Qk_nWid%r%u$H~6Y37H5S$?bjKGKmsi{q~(BQ)@jwi>IvpsS0!M z>+yd%3s*UEUO9ZzPWtI`n>rci!}UXi_z#w5PtqbDPhg&^cjj`pI&>dQS2-Pe`A>b0 zdth3t^)2|*H)=mwljx*BtK9mY=_%E@m)cFrRc*6Z@o`Z$(TNx|voNqsxMVGOq7fu$ zof>jrIT8Rcjz|zdy!AeLek=+F==77%rgiu~OW%CL(V37H0!^GT!-x-w8w!ss*`fo# zWD$8n_nWz${rw>#gi=VNFi=Dv#iN8^G!#;qAxI?xxeMHP{h_tc<^f^{tbP$5W^x<0bv9wfk$ z3MbaUub(}$BU*jfn{;r)X{LVe2V9f$zqx_2kJRdVj8WdQQ#(2j#%vINtp}=Je4rlv zXc$Pam!FcTE_9;x-+Rv34e`a5+Dz!XK>HyS=LI|DHU+|J1|CTL6!x3EnE$3WsxsSQ^JhXyFoJ>U32}ublsG^= z&Oq{sStzLwl{v=2V!{KVSjVE3LZv|vS^WXWN90*|?gioVB z872h8#&RmArj44V$Oi6UP~hZl6W?<>K5s)#9e{yCr2TMMFh)j0sO^Pdi&~p&BBKmL ziBVxNYbi=ag^p5@7?#FS%3=x$6L1L~lv9^dRku|1f zV50{m*lZGpO=K1?ueUJyUx40&2gnyyjBy%KYJsPgU8am6Z#mD4d-I^|SlaHnE@O?Bf>^`Qqos_n$LZE>#jam?Q?0?RrH1P-4CpXs6qt%G6EDL z01r78LXjy1{J?{fDL{Zy2vQ{_C{mAt7YS$_ha>`kJozDhNB{DMstprDL}-7`Lcsrc zNS&`wFHaw1hs*D8;{?>b$HWJzQ={+Y&q1<;M^^wj!e{lKkwdy0|3iw@rlP15QB9cP zFrtbm#1U4alvPz4xBnM149j-5l894n1x74nP-|5ZgIGa^PDLg`=cVU933`9m)a^c& zd1O>`_~$>-@U9=Bdh^5Z#=iw98h^a>uiw!6a(ei)4Cic`*Zj{o;wetH_}atZUK3#b z&Ij|$Yen|`xxD(ErWNb)n%;9Z(hp5GO3IJKd)gI~{R!h;&un@=tXNw>zIm+D#6f=Tw|H5>~BxOo*b4srP+yn~(Zx%CL=lxEyEkyqD$jt3CJfPFy!9fmAF6VZHNYvu-J%be4f z_w~XP`26sH4|4xgAAB(%;}i$o$Z8M_{*;E0S?vK)`aPy)#2QDYQ-F2AdG}2b4D%J< zc_Hajwre85o{?n4H{qYI0l|Mu48xa@`u)QLqGTN{bA@sDD?g(l<+1N&$=L54(amUr z`$zQy&QpXWm+4^?^n)lQP*P(gC{Z@tO-ZaJK}Z*#u%LB#ez<1IA+3ZmFW@xmXa3JS z1N@@>9q@23PoP> zWU7=XLfXp>E-kR4)g_5PAzaGua|J!zs+idsq^*#PAGK$Mft zQ5t(spw5lxFY2v2JK!_nP>(#{Qv=KRJ#~8T0W`AwZox#d>V|HwSY=r&}v4MOf!c{U06oud^*&lF#e+{Vcpc z>wjU1U+sOFVL3lz`9bukbg92<@;df)%)K7>(jRdB*H2x1HV5y8KSMYDGzx$z#FdA` z3V`KaFe+ghrGn4h!R7k3VGPO`ulNGH*w3wKIiECs`Or`~L+bso)7AUoCR<^bZ zF=8oEXd@a^GOAlA^UJouR+Z5?wq+)&D1xPMW=2&xlXYclr^Zt>SgI^h771{2Vu)7% zeGI_c0*I9VPN@xH7C~$DH^`gR_fob(I+JzOv8>Bo&O^|z$Aka6E=Pzn@KIb3fG546o1Ne^V)^1E8fl4!BPlUstJ-rr|nT3#6 zDvU9l%*a#`F|I_+v0Rp@B@&cvN~T!2j8Tv=g%MO1R#j>?gKQ;Xj22Fi)&|=mAV~1( z&Pqv6l(HD5rLa?$(!6sesuKy!;!&>|0zkY{GAAVl)J1`!HpmKPHzb(MYe9&x8_R~g zPBU$(F1H5IcQIxmMWv0rrOIvKC^?R0xU5tuS~Dq;7GSYO8d@g^qyFnSz#(7^@}4ADNm&jg zf?H+tKmrE}i^Yk>;*SU^X>;~BK^`?Xoo>SunGvm}EsS$1I?c1w2Q->fWFY_*grmn1 zjK_KEb%H%xRibrDOtkBV`kiLmL$Nv?Pxctrme6Qh8aaxW6)?i)8JT2O;goP}MMa2( zQIMl8I~f!N7Hodq9hgg zYPc=Lu2HBZM#>g}?;^mu}|9mziHXSSL=4AMHGsxjoz^vs5& z@T_mb5ceTUA(9yjkeLohIVc%rkc<)~DtxJYCL#hWsmhtOEMTG{iU`)CaadR}cu1-# z15iIKU*RL}Jxm;_D5;R83Mi@yD9pg2!2MDh9%3WG9+HJAw1*jp2msJ&fDi(6cLY%u zAc(OPL{bVoYM_cK7P7Uhf+QT|z8e7tY~vy#3Q4M>i6Ww!B8sXiAflFl#C$`vxhcv~ zpg9Y2#7;tFkp(AV*wRwPh({AQQmrRVBd9crYCsNALM0u@WB`BJ0`(%CsS&CHr5Xe} zfeztbOGPh4wB!j9gjF$usIJ;YO2P!kQl%g?ISL=?gg0NX0_+b!N32BWB3-Z!(4;(! zKs>r(s9e?>nh`QuLd*w}2yy}Bp-8z3lq!_3-WBYXeCrdLnteiKWSJ35^hz91fZMpB zazi1ZT3Sj2kUxnBk`$#WLbR<46rz~`WT{Ajl7$Ln14z+HOF=z(ggOqA9 zkfM;u3o+>_koJ)BjpZ^_q)J^%Wm@4esiG;?7(o=(E#7jPVks#ArIIOngQ-G+C`u9{ zj#CmvO_D7kQNAS%!xAadP&Fe#(gRYuB7mi71w4_+N)Qi+Dx>6M2}LUsg;YglMod&u zlqkjsqBeyJG_r+1V+?cnE*k{eB{ZOl=Gkp2i38MYe$1_}{D z$cT!Tj3*sn89#k`wq_AGvLE<-kJE}2{}*X@@G7WPo+8w4!GL1K4<*O$XixV$z9&iV zFHQj=2@jrMFIV69+q`oE^R6?P3`D_;v5%KBvAsA0HF&ufGY5zgIoq6?Hj(0)@o^Lfg*wcbZoaw0 zou+r?>E{-L31$sz5Nt{yC!+t{J=$f|XzHcdME7=Z7j6K7S zO*IqR?Sv%+1%e<-kF<|e(&j`8kCtwHT~`8jp5I%3T6^3u_q(Q`Gqc($(Y!$CWzqx! z$`^4O6eAlVf`J`E^EyMxsku;M7EdVhi2#H5VOU}YnVHYJMm+N{)g|a3UxXuJf8iZ- zLl&4d{vcHJ^0383#za|0sV2PV*L4u-d;0G6_0P`x$;(2s79%T;D;6SSQl@3gV0-zn z!PA%SpBY?v5su;&B%nX@JdN}tiZ^5Ohha{Oanq4KK_4u~Lppc|oa=+YCe-X4rU>wJ zEKUnrP44hBWqA{EMtJ4o_Y$T~Cfx&>pIYF+gN;3a4cS$8kE0)tiGuMJXF2NC^M>+^ z%gTXe0bGPB$-Fr5Prkvq1n`!)3>1%GbI_s6Rf_s&w34u#N8~+MqW7N#^%i;he!a|B zt$pOg+VMJ}%Um9-r zLM;*=S>E|DZ2N8qiaEXI?(Jk?P=bWq7yyd$9-`WCpkUjAd6s?w=RkUZvJoRtL)qX} zvmg>H&|Z!kD>@;Rl>I!iYw(L6>^t{Pwu&S1XdQf05kt=o6hx&l$FV&yBO~%$p!3&* zNd%L`QRcJ|iYQi5ebP}01&a07WKJceS>?Ea>4szO^}U@OMI5KRx!w8Z?mxda@E;%` zi74~T6#jZ#-&hJ`QQ)YEq%x`Bui<537RZXq}%6DKY^Hev^ZDr6!p1M+t4P@KuSc~g`SUh-dp zgy{*c6XIMZbq8?*gA+4ES54EMzD{W{12A12nP$CXwsVA=BxeG(1Pp`+PjP2ubWT=8 z0vC}ZqzpGC_=itx2?r8D*NB|<_&1{Z-fg^lV7bT6q1x%kIWy+gPG@Wi^nk9Ru(d4* zh-B2&+M0e2#@1$n;JJso7@3x0vR)Wq?^tnJJ-@dN$SA#DVDBaE5Mh?_h!uM=1W?&% zhT;R~s!$U!SIiwDSAB;Ff_5V;9pawDCQX@xe2~&&Y~m^#$$;g87v?in+&Kc~P}%Xm zqo>YHI?{+~RXdAhAamG<$pivNNY1V)h~i2}l06&col<65QbIqmo~-|0e~@41kT9!o zj64xw(TVo(5=kT<+TtPEei8y`XL}M02KRO^54eq-ZwqAg%K;#8b4?q9GC*R$^A+kw zBX_;f&x_nTVr2Q$W~KHIk|Tr$e-%fHqXosZx`lWY{Ppw70Es|$zdkIe^{b2=`afT{ zfkjkTUB2_JGn(O-J21dAdGB8U z8W33IOcwJdXIfzpPYD2-5I}{WqGAWNa4~EO&h(N%@hvyEUJW*A>~e!~8PU@fLa&e! zT%*nQ7bvaIF@Rj}c@;vft3$-+UAtdLSwOu!K1J2#tSE+A^uW}Hz420%-R0Q$xb^4H zXRk~;Y4`1@Jn%+S4-C{~&#-1#?u2_&T(o){1feRnVGGFBr9Mk@(apWXdWProNK!x`ww z`Fzy?#4OqQvh+n5$} zGQl)u9@M}h#mZ1@zFojai7D|^Ac^X!u?yA4R7b2Gp8UY3P{G{X`ZtIrlZu68&m1FD zF@sRB>OJnu!eK;Bi=b|TJdSEWnv((o6WN&#F8<=43>`s&@L++TJb|?N&ISI@|5AWS z6-1w}>8%Yk9dF(>Fz*XSAKRavytSU3XhZaxLuDN@1V#pPlu81Vq=S<}Jh#P9ryrjz z@LxW9*FCm08}FW>@4tZeL;MGyBV%rTclvbj{5#_G1_1ShN>YUgfN204Mvy1~QA!0U zL@7p<5T!zdQh;dyDG(?^sTxv{8bBIpP$&un0wqY0T0kfo1!+Qoq(Gnw3Q?sF|Ad75 ze`lmVB&kIKOL>_rK}`W!8TRmgxMf9T)h0wkOi4>jRqWy(j|s-__V)MR zNDE-drPe5=Nh&0%0E$&f1pok|s6quqb*%uxq1GvwWT8-kN;n z$*zFAw{^liL000BFvE8L;00Fr$SxUhnWlGvy+HJPP&~DfRVAn;hhVIj# zQ?LWQdiOiP8_t}1*SW86d%$!Aq!kjVLJ9<_MF1p7pcN_=RH}+nwgy0|01^O1fha@? zL%;w400YPXG74JMnQB|yIH@WqNg_!A0C4~S4HQz8rBc?246=$E%T-odm<_75BtQxj zLK|oW5)cI{LP@o;NBOb{0SYJpiBq_W%rg-~eX80oOYK4(HzR0DI5403UPi z03UJO03PRX05JdwxL&Toy}$qf9)JhD002GB;g4h7?hM%O7>>trf_53O&4udhXSe_W z000i)0?Wak#x^n7WFiw|9fs^PW5+nRumB!_C;)H-dyLq`M#gMn6Jrq(5yo+z-~jXh zPzndJn;Eg27>J07h=h-I%x>HO00Te+Y=nxUsHh)oU4nvucv#j4KmZB=P@n>+sdhJ` zS~MvoIC!i800Gbd000000001jq=bY*Qi2s*fB*mh4C0k3j|0$9q9HIFFaQDc3IJNn5RY^rs5H$b*1*F4DDu4hI00000Py&EZ0000GWhe-e00000kwHkn z000sXWw#3Z40BYsXRg+^-fVW>=Z_xNJ;r(Oa2l^6-tK`ORyuoo-H#oDMPe@6kml8( zUfo^Ra*;OFaM%`&NlW;nk_rUf%6C*1GQ61W{RN zz1yiaZG#~y5g3gvwNe03?{4J^Sx(V2e#esTG%RhdJ+^4;9mD>5Oj;o z8dGc|y+)f_?(W{>d0g*XntEM5=T&a)Bw$BTJA2d!C55X8pa2ecqTBg)X(g0XEddri!2d08msgW>Hq64L|^bsY0S` z00vb1=Mm1mPa1o$%Zt}m)A~JY%2+;aVMb?m7hxAf2qHPQx$kSv+$DQ=2NXi8Q3yqGDxU18JoFBEAgZ8I z?8@6?GE|jPB}-$yj+bUCdc~**w$Ra4BH5)-02kiCWV0k2=vsu+DuxO=3S&koiH3yirJ)t zR3eE`Rec2jYvRyp z08A4`011S|$%&%~(S*b2>H2*AKL3Bm;7@Ple}D4%6jekvKB9`Kgn|Ehf6hP4|12N% z|KpqgOc2^XoV2tK|M}>D^ZtA5R5SnW|MT%av;X()|NOuD1CBe>z6>)@|M9ThfAE}n zIyo^QoqL?lw8Ve#JbeG+&4>TpO|Do~|Ly;`|M$Pwi4hOH#r{_iql`jq(4cop4MZ@-Vt=Xx$W z@&wtSWY!~R58Qk8mrm#}-> zb-ljUVS(=5qiFqAL?6UGwefEAQZ*OvIvU*=2!dFTT_4%6tF4_v?fZg9Kv{XK4@ z73%ey6VvjVJ>~KUV#mL>b*^d}a@*I&dj7wCD+`W&u=meZT=db<+is<&R7Hj zQ~(37Bk$+y^L@JG|8M^uy>7He!-7e`I5;N&{9DH;QEB-UCA8|tsTSw+=r2+Z%kiEg z5BqKd=a}|q9QFIn2pgZyHac%!VLS`^aNTUnAbx+vsPUYCO9Alb8@w{}2Hm&!M-Tb)#0idD)5hd*r#n z2lZjbi0kG<&ki>jhiS*98ipX}KD&B5WOo-Zo9HJN624kDKEQ$shm* zf%|+|cK+!4J^Y8CZwZA1CefY`v+dz3sLrBvQQA0ro0|bCiS!bygL4X@0h%i1! zyaF*3b~zV1_}m)7L}DObQ`cP{n7*y}%l`&rATHQM5tH@qb6fMSwY(027^kHY?BK}{ z&o}U2m(+WV`JeiM{)qVqwxfFt!1hG>n>Fhae8sFqxqIC7Vq}7a?+m?d-r2?x=d(at|R+hUptKNe;tdrwsE1yUB}|Egd^JhKiP6qZAY>?+?!5^CSv%ad=7tf1(-|Am~uPd*dW~=X6{QJ=Cdtg10qk)9X zGe3VbIr%g@`});I?g#5|f^Ik60lmwU>zdB1+fpyx^9?6;4}+7A+x1&ou(}sq4?Gk7 zK>F!IDUYXCpH{_>B%W8*g44%plWRGAX$0pcPj@`$_Wq5W0!Aoo@N&;F!2GSDpUpVN zo0ENb`KIBXzjZU|zEd2a@eGmpLUj^?lLZ#ku$xTDAD>A{KiokQehG-iu%GH62iQWd z<$Y|%6nSC_2HX#Lr^MFWM1%kKam7DzQ6chkzuWM2UO3DcuXBzzyjsZo_P{w7K&KmL zz=7@e7+QFn&3NJXsi>rFp{{yPTYj*9&9!zvP(O?+|LW&C9ioh$ff4zY{d1vaLw^0S z@=n>qz?=u)=QHVsLG!Hj_`^KEr>6KvI@UL*_XF_nJio7f{vN;12k-FLUkw>AruF$5 zUgG{{o#o#=b}#*pM;O2iGP@y|J{)Ichwkx+8*aau?Ny)Tz$Y8u*KB^-$ooH8^nCqw zkz95O!6dUaQ!)`r_3KBrZ@%8SzB>Hp*6w%TZaLrng36evq?JCg$UNiwUs^{;@!#*U z2rm%fgT}B70ptcQ#{l21cwzXP>TKAEHV*a8n3@fj8gTq)S^1yz@u*SB$L*(?=lKso z8BX{F4qviL`_IdhqS2SU>p!h6#*gZ{es@!)m*If;x{u{zwQ*~H;RO*PMI})cR7_0? zo($28lmmB9w%z?Y`1|q6-aJpVx$&Jt-+z_C^W!-4O`dt>yZt-EbAm?YbTQ#s0A#Tqzhf8x02dRTaWTg~dT;AsNXfN+ zr=?6r?0FzMOJLeMR_Z&bh$*ir?mc=U>MXPOrtRk;e@B^dA{QK|Z%1jgjFL zN0E>^3<(jA4oLJ?RKX=?B~~H9%aNb&r=Pzm*we{D7>xf=Jv+NTLiDu%+jPcX&aorh zk04p}ot0ugwEtWI3#cXoe*E;JNI@$|gE{6(@6w3$)*HVYY#Pkp+xSEu<^m{geZ9}r z`1^Sto1c2Q=(+UZN@yyIriuzm5}Kr>p($blh=zmDUWdnhanY^(y51;)DdSqyG?PkKUAn|2Y2fj`-zvKjq`mCDJ+5?(PrpzsElu zzoLEz`b?j6lleR5;Bm*}!}5P0<@*4&J-?l$=4LSyILkGH))>fQ?TxO{%PSZ4kbc`_ zIm;BctT%T6Tw(ClDe$lB>-0W$AJKxc-h2GD?*UM)*eGvhhEe@HZ zL!l%8YU%ejPYic+P<^P3D9}HxS2JOl{Vg;a;cd9M3@$g1oJu-xOEJYRODI`!n4@nl zBpKEwMw%midXGQrkT+4ECB~-dzQdQu+ffL+zr*PCDsuweTt1J_i0g^uj`(}=H`8+c zGd0-MUSOVv&YHEC_WIYEjmS3tXH)ZBd`dG%k1fYSVw=+^h&if?#TC!fs|TgJIUNxE z!E}cenW*At@TFQv? zV6~B>^In6?{oE$o>BpQ;8HH$Sneslp-fvomudL6gM*HCl_c)$(DKZ&387`h1`|zO# z%JAWz8FlX8&4prz;hXRF&HBGR)%WzB?#v&N>cn}Utqli~&Rd2KM}`OUw?2$z%rV<{ z3CNM6C*wtDQ>u-w+G;O@xbx=y=1KX_x8`&u@*^jks$vh>q!)>N%H(BAf}CHqxnIVP zOY`|7wd+DT6Wz2neLOpHQTG}*+lTE;^tiMW2gz>_9a<5Kn|l+}R@(=QYxtG-r7f=c z>ZiMUgc}g>@@KYemg--ww*NL={>tVeGkbTY4@|@j^m5}`e3!ZG@9$orsPN3s#5_rXqc08~rB%|1O69LRAO7SpE<{rRk_FecJ~g#;4arbIR9$BW=}Q z-IHI}ZZIvb^*?a4LZ6z3&jfuJ;-5!b+oM56M0TT@<=Oe&Y@V~Gdyh&6(&d`?LxWz} zOXSMgtgb@7^6YtSvDUvlcZbQWqbB{_6F!xW;C7$Np%+#?`hf?^kH~9x6o-3kRQ2e# zmi6Q=Us2rWawYQN-ZCf5v)HK2!)ji;E{@S7j<|g3+!vwmkNvgk?fW|ip5Yb`)bpv# z)<<%Ff6Q{$O#yS4Oe*_5| zBmg)#I60kiJ%vO60t94|Jyt(a+?Wi1Xd|?$e^>CCw%UKH_+PZ}>--(^A6?a6zTSWD zy?>)fAeY9mhjt7a%b(ev0}osFo0B2Ww~fI2%m@7*&ZG1W^dvzIA3sk6jI;6m`cglp zcO*z7Kk!=*=P+TlU*r1!O!*ndr0#nI{KH^x`)}Fvv$jGwkqCMF6T(E)r2?N4B_uxP z;r*Bf{$w&r_FdqV2(!vwqyOPTAEQh; zej%g!_os}w@50tUh0n3+QU+LXGxXO0dWOJw)3GN+f8;=`KlNWgPp?RCXhuQ=f+R2S zuB$cInC1*0!xwfZ{*6<$$VKZ2#$m5I>>;`KDDpjMq5rK9wrMHDc$fjrg8t_{BbA0 zjNAW@U4JvQ%vrm9xasly@rfHg6idVRwt!&aUY!0r!2n3xWbw)wooCs@?lYTt|1Xr+ zl3;vjk0h=^p!NYIl1Oa<1KR(YXXH+mQgoguV0q47ZEkH&eEz$~?asrTK#&YP>nLWP z@TJhh_I$CA_uD*PkL`y9oCTcljO5V){+lo(9Ggd z=GYAU{Cybyo?l=7QFrSL5?${&&l!tF*S3dSyRnl<&_xzH{*`B8rb9;-Vo# zP<=~*;gjnH%O*L(K?IUX!2~*?$4yAP!3;;y4}MonXC*i^|E^`V1d<1E#Q&3EjP2R_ zjP7Ur6Rv8iW`T$FP^0aK^3OlR_h>%avgRvDWB2dG?sGrZ{zgoq;PJz0qsDIB&%C~^ zRLxFxYh{oim+Pp2xP8Ki^?zRKr4KK81QDeIl*DYL<_E}=2~?3&5Jjkf43QjGu*{|A z+dQ6piWFjI$JSC%Sf7)8KE8Hv&4&wXNo|U<{i(aJZEvO>cx7SRr^nv93J1Pz=B;*H zp}HpP`EqKMAWD!dHRMSL6CjS>O*x1e{8j8sa&4dDaC|%$AJoKLpc)P#pT8R>OAao) z5${b#jdlR|Q5YNpj0v982@q!l@F1jz$|KqO_pc_;8O$n=1R6rU!VllF{T5%yuj*G` zKt&&(_C%Jx(xykMhXilJDp-}%fvT8*wJ`u@TQJ^)F#J3hpWh>YnesWGIOfJ;z03}C z>&x6KCOtn<1Yeo!ru+g=nr>=UdtoN;YvwojX^ZVwPoYhNl^$vcU??0EU&W1;i?>t%hi%$(Tw-L$xQ*owu z{3@XsOG zm>@r0RPRz*3~P$}8rFx`+9&qjr+&K~<+_IT@POHd4fgy`n}^As{UOqCde?{WiIHdK zy@;M8sc5R#o~HFnf2YUIf4AZOxwXAHJii^5@AS97GWJNL{kXCqh-MWn4gAy{@_1_G zp`uBPXFZhv^9=k--{%v5=QI@WZ1F$eo%{at1Z(=<;@R3#^Nj7=P>^71?M;$D;cw7l!@uu3P;=_R!lidn*EUWOzVj z)K8}Nhn;zZ$Yw8HEM(E?h_K2R>jXxjgxH8T>EB)Q?FAd&&u`Jb@^}1Pi1lwhWe^{I z2d{~sAc72);zj|0`@h|0Gmjw`miz5}dfp~?ir)}JLzdI<&dWOIhN`8{XWn`p7-5%i zB+)`2s6R|O=reNN9y{x-zFRam#11Iu(>IE%p()}2a{r1Yyly^Q(OUF$V?BafTs!;R zXdiQ^(Yo@1eq8=Z!|Pf}e(ny!T>UM-pDm33IYYo;-%X93#g4yJ{M`;2vUDAZa!L7b>Gs6jLe@KeXr7EaiPfW~yzC?nTC-sr0WSl0B z*83&rHvL|61mlv$elz-ET!tkOO_@1l?;Y+mct~DfXKD|wtC8@@76Cl6Apak}vZmgN z&X>@JcmA?J(dSa_?)SiO=Nt|OixTuu)Y1L_K7@BbgebQK`C@qoOa(+g<8|(55VzDZ zmSERCsNv1WKAZV^{ZB$K2UYaIp3aC_{BxlE*kXRZC%W|fjv1~6mn2M%D(hYu_GLs+ zexu0Ki4Eph9frj90khXRcxFKUQ^EGmo*gF#LwerW?qmNq`j!v#H`)G-<-6WPxh~x^ z2kd@0^l|r`=;)Ln?Nh3uLa8f1biSs1`?6n#E2}+!h%CneQ;|G$J8K3QM2>2PN4il{?fgFVLq1W?bQ{2X z&Vb{G>vQ^Pkksk3T7EB89N0(gm`hG$@#lS!BJ>Vk+txwp;fulK79xI{F`jQIpm=i( zS^XY+(0uYopqMj!eDlWI_C7;%T=ZMuVP{)mf4)9I&!O&_j{%$bOVf4XbwK}7%!AR@ zvBYtO=i2;)HtL`6nI9X!YMRuaQ8k?XPm;r6;Ba*9r+D&X2(Z(N@2MP4_Z)K&&^kD) z;>20*+KYG{gpVRi^Zl-KiqL+zk@^ST*?!~qrJKI~f1{Mn@9bwBcv`WG!|EYfbK?7) zh$=m(KD?4DZm&F+&5RQY3xbClfHhv;u_9gEA1$@-FTOti>heUK?jV<^0#`E^I$*4I z@~-*MON#9TjDJxRQ6{{ofgr&cig7`Ot^NJ`(|O%f7PPv*wte|S>$HERl~5$0N4S60 za7QPHxfd$PR6;yjQH@ypX0g}M=a^`IUqRuHJhy#&Ygu+@eEBm+yyT@XM`FL7xE6hS zzx2dPecT`LXT|KB3X1tqL67PXfV%-7AXASd#U%sz_mP(X5Mt?DS?aIJvU&DIoD8Fr zG~!J8h_RIb|2530{*Ta$tUT7i^{dZ*-g(_87xE-Os)|?Ge2=MsDbmy7Ww^ibH=l3O zI{RF0T(u`@?fJB8(`*|(=x1cW_r<8=NW5)=nEQCecb6Q1%sM{^TbI}Tv#+=d=i4YT z=U4l#ahPJ6k82@>+rtGBEX1DgSOb_E>8eYy)1+LW97!JwB$z-LIXrlN@?zKg8$V~6(G<^QjyRH$&{ z>nDcw4H|cLeGu6CW^w#>_vR{%N^jPqd)IbZsE6H?uhP@~d8}4?(Lb+?KxgMI&q6`l zUSYvFJ^Y9r=3)b6WNr)hu@RpW>UtoY=v!mX=k&>Rc}xf`on&pXdhMSKakqqENI2OH zals9O2`3&gVJ~!szdF@->u+L9x(Bn@&ZvJz^OqIhr;{^HccO1@k z6Fi9Fuj2%DCmcm$^5x6=rjrGOL)ue})Cqi=K3lCTJ&_S8tP6|O24qL6GCxR9>)7k@ z(RiL&XUE6VXW6Ot?;KG@=hk}IaU$ad!xAt#;>_bvTxvPue5St@b8*A=!&zucxj&|j zdTuI3WjVw0r}fZod2)v3_@CDK2O*)oQ~$l{det5|=o7B?z~s&m zetcnFP=49?O?i@ajy_Z8In0YXIM4kyI5^o9K%dFcsnbMv&%BPWBmT=kF_%B{h{5#EfR?P>tWyT$vzU4%{eN*<%#|rKU11-_=H|4 z9u`3ESV>_^SF@%a3HbCaXv5Xxft zY>RA(m9_nkqlB*}EbrdQc9#cSTj}hq?d??3D4EL7k-J>S7Cvr{iRWH1!-?NlVV4N- z6H4kJ#@ihq#%?~i7aF|sJUv%6&Yj}ie@D=g3e&s)a2_;1F!+s?k4)e(9+<2P2KPDvo*Aa|J1=XQraeoSg@zOO5c(@z1xd2x_uoaFq8{`C*@^#bcQfDJYijwz@|q#$#MJIX!)317w%>d<9Qnev3HU*mt7&YU z=>2TfoBkNQpUa+aBn;g?GMUIZ%I*JI%vsrt{+s6E+g$Ll^0;JT z_MZ`$UiZ{W@}0^o@E|;@jWb?N*4X0N%#t67>{wmMyEs8SwGk=9D4ufE=zePcKMomB z_G6v1f<4tZ;GE;=i$C{G&x|ygMau>R->BzE`lwBi<%>PeLAWi)sL5%fF_# z`LCSrsBwH+=MfGv+&mqA^4W=fG{E2QS&Mr28x!Hy^{+Qm!n(1)YFn27lD&Uc{!f5# z-^{~x)=N+5x|V0BE#p3ecRsp~N+i`9VQ0~jn3kFO<~R9qVo2?Jq&@#7ll)L#O99+3 z48V$^22ZCH#iT2#bVt?LzL@STrT*QznEO|vllfO%k${17vVFUUHNGAAU>!D7LmWfF z8f@^QgR#kHm(xp$5SDYzmrF66|6Q-^{y4ti_N9vYg!_-wj`XCFh#uO6ar~4Q$UX!a zkEQN5AWQ?_^~#|9~^QuWC6Nm-RmjU}%pxA;$m;_n;I3J)#M+!l0zw--k>40z3>*o(Yn)Qti z{Po0k!HcsoXU^vlEt~q{wIWMHcJKHOU!Se~{3Bmlg~W2lTU%w>{|Y?!`{lo65*qRa|2X@rT?w5G^w35~bA~(K?#v3y5p}U;8TwaD z(z)iB6F=6)_%B<&!X)Wyu*@Q^V+{}}B2_@M4u(dkW9zZj5VdKAgF<-0aK|$KW#yx7 zmzlyow|QfAp3Wv)5_P!JJs`R0KT4SXtSmKNigC1Kgf#!qw7dcD8-wJ7}}J{5ndrR{4K^IHNA21?mt7g+z!3 z9y^c{1sI4wNT<|{1VJAXKN9CkK8R#R8~%Ma^k)%fDd!#$FUhgza~U$nn2bP+ zfMz0c-&NzcEw7w)dC<>uT|jCqe}9u!rH*_cgIM?MHnyKS;kIAnym;P-ce7r**1M3# zw#^HV_3o~}w?nAM>py(Q5cdvwzgf3pJ^FtJ`fP^UTG-Jb-^-8Z{PyyFEPTY_s)$`MUXD+*sA4`u7x^q52N3u6@IJMX5FGz% z*M4dE``vukoa25r_Y@i2)^b5|3Ik@0Gy3_~DO}MD0&nIz5s$o7RfM106XPoNx!@s4h`uB4_FQONt@=&c9NNib3PQu13FWlcD z_f%k3TsME5##0~jm@uq{8ey!?_t6lJkRbKe<)kxhwG~w8=N@rd%&Fk*q0UjG=Jo4S zTHAYdKUN4ZU)qO+f&mdSw zVShPRsE!DU=b4X{r!f7x_c`JB-e036UY30I19P6Pdpc=!;A84&fgGe{%Rsz^&hWyr#RQw@_xABXj^h1^CMi1dS$3GZHjjcPm zgG3Q*#081xPB=5`(-G;PBG>Gl?_pe6>bVcNTZwW%!&UxGOZk`X()Db`L92s+pr6tg zJIkjMq{KnB>27s2uPAWkiYIf@?!_mxI?M1nr7dcYB+(I z`R|=*jh$+Lo1doE%pG_`Q~PZg55SFoyg=m1+R$^=QZeMk!rhxt7mN0P-7i7<{23V& zmqf*(JKU(gf}C{lo-j>yy=uL{dAl`HBl+$GyC0+yi2W}1r9Qxzy@{XoX^XJbm(R^< zU$@rUb6c(_TYdyTW&g={9r)gwS^iM^xXDJS|F`!!?>?YFed-R3A4X+_?B%x;_Olq# z9~*<-rz{q1Zv3dlkj%u3e+`Z^Jo}HL9ZTM2%;i(0g<%vA9YfQB5+LIbZuk^H zU)Zf?No&yY=3Zs+4R+%G0sC-k!5E>`SdP5BoENYd57_`Ilpey(;j_}Ga~86$aGWWz}wcII%aKm+IYu@ zjj_DK4I8|?GdDcn*Pi>XzjN05%VoKDnc@?-dG@Z(Lb(r4!20#f2Rak$yX*VN$>7%G z*>lOvQ~4a}I~~0DRK~>yZK1gkaOT*M%hqBC(cp?qUc-RA<@vm@Ecrb->n3m4T(i2@ zG|$gcrhL~Nw;AK=dPB2;xUxGu&jIiaeQVR9S=Z96#@nZm*UKLoH}9P{)aL#1+U3G_ zD&T$%`W|Oa^80=l`HIj)k@>!y$~#RuB56sITI6z}BG>glP`FQQc?UKh#bPJKE@U(= zdBh9 z@y8mh_(C3Z&H7EIl*P9Hw)(hj=A~!vfF!@pyy_Pu6Z7eQZ=yOCMF~xd2N>|>+Yrn+ zD{MafQ|KOu$;D5s3=EtMT!`N_oMN@g-Ql!7&!af(iW=Q(Cz0tl4AxCEy(};%1czYP zmxI~wLUQut^YOHlLI<%P-MR9)!`^+Y&*aaS)e%=UmDz!-!dvFBCWK`MsB#_*q^1 z*(@@2Kz0=LHd0b=O{=~h8Rsp0ocsGL)Sag6Jt(+#$;1tHLm=?g zm-*;uJHOP`Xihg4f=pwt z)l|&+mF~UIxSCt)RC-b%e=U3C>Nr`u_aicAklZsEr@7KCpIKm|H8&mo?;<5Ar-d%i zWcz&_(7wL+ch&RAgT6S=7ao76nj3Ge&n@f>`B6R>TsnB~PWXaN`hl3oK)#0_ICy8N z&U)T;q~BjZ566Kt!AOrFbPI=fczr{4!*h&DuuxUmVUM!l0%!e-pFwl)EKnAG7;}e% z>xDkQzCXy{%I3#2_x&|Iw9H#{?PEEN>$E{LEz^dR&3C>~wF@UZ|6ZWdxc;I80rT*O7p!!L?4j@UvV!6%N=9Kc zTp&z!L-_?DghHk2(jfvqUjA&lg@KRb^*rt>;E(el5z~2=^0=OU_PuADz-6+>?$}b- z6wwYG0$F;YS6%APR*%X3EH)9F?YohdJ#(lf=Jxs~zh9?!V@K{gzsH)C_%WAYPjwsT z-g)d-v3|Mof4SS&HulFES=?wIWtyACk0VlApPb9Zvy~#dGnY>zN4+xfYrEpbbUV0) z#u!Az2DDVNS4r_QSMYvyomX3vP{&*34ZI>-@Z=Iy_X%k1m8L?Gi_e^#R1$X ziMGj;`LhSm;&zA1kmoP1{m9AwK71ksj<(%Sh_R>sbHrt@&2T0UsEqksk@$TP!0)y5 ztB$>C=dYQJbVrHx6&9nMsc zbBuoD9q+}x_V?Kljdw24t{6kEI?bV`a0j9v6_*+=0cBCn=>qfkQO=q7)ipk#M+a_* zhv^o-wcjpii=R<@6eK$NZXPinhq-uo>WA&1>|E!sJ!((m9mZ6Bm!5p9sVV{?d{AJ} zsWRQDp8WJ2`pL;=dM!aOOqhUCFq_qPm0iIq8ky+k zcM%-%8Esd5r_)|uU$|hPpS|@SraE7T28D9H1kqQ7)MnI4hfeUz5gm69QRJHqjVZ1w z_~Igyv6;;sOV^|waK0Zlo^p&f7|H(^-;rWEADvCXU;c#y=I@+3r7E~dB9IRaaYEKZoZ5m znIY<7Z%@5En}DVFE1fS59H;5f@p{kd5M<1G?ZEoj@Ltq-wAEZeiZFhA@dB&l)M|+L zd+kk7qaHUI3OuqIw9aIi7psvjq4>MboOATtah=+AjcWw`-g~}lTtM7;yvIMb(qd<$ z+w$tYzbee%N!{^Ck1pZO?0?#4*<6a}EC#L zBEI)MdOq{N4f!Y3U66pYX>cR@#nFs>A)k@g9A-q`Wj1C$|Bx|fEtAwskG>lme$706 zdi-%-qv?ADb&st<{88&E*Y+GX5&f20b$(R%f~2p|hMF(Zk_bM2=zP@vFA1-3qt{Sr zIOp~8K_9E^VW?YbkDuJPbK`u{AHPxUg&QUJEH7!F_pr2{T+wU$&TY5cap~+3PCTb} z^jK_k)#j{{GuJeEk69J;=JYNhgX==mYmGRPNSfHIcOUIAEp5X^zz0+-m+2!aRC~EO z=iFo^owrAL*8xIE_E$410I6IWfc^i=6~?TRgnu97Puqy8|7q>3|7Fixas81^1OAs3 z9pRV*UzL-@zw0{wO8O1I<8asdk84}&e}UJ2&++p0%tUqH?y7yyA&m~2x zA@=M1K|l1n1bfXR(aQX9y69^L-8gtKK-@>*KiR~Xg2|vivJnt+QVZN8Ug`^FefnX6 zCdxeHIcN-tZFMuuWVwSN)36+uB1mQv`fm>N`z>7ixazr%4%w;2WEMY7QtfPu7aeq9 z-}th(onzIXkvDK9=j+t}lo+Biqiwm5UG}p)nB;czyu4|IwU1Ew@_A{9VA1c+HSKZ5 zjywM&@oU|0mSe9p<&DRzisHLo_p&AD?%fIst7O;eYE{0*lhOI^#IpUqZu!?#(!=_C zu4c8@dEe3I6YS7T9qg>W9w5!-7HNNQGx6rr zc-(aw`(ee+a?jnjFZ{TAz8_5qCNeb?820g#`^d;KEFbHlT%BCF z{dB>~*8``woEtmL&)T;YN*(*w=`KqRTTC!ZS)80n_3Jy2Pj9WipWfaWef+)>j4rgF zafss=Fg}FipaRYaY)W~KD!@fuQ{+dS_-}{*#WHR-r+&`-(M_x1Mi|glEoWMEJVBK3j z&2auem?NM@;fJj@^@+oaUDOzo0&3wNu66*E{a1f`ApI@$&_`Mw`SH6W>ENC}hNJVT zvZN(McgGj^AM$;W?x1v^k?EjcEAD+~{C9JBsYEY|&)Y!Fu9oH$1hB<0OMrt$;5LuM zJy+-U#d}W|quL9Hnu6ws2-N$Wu3eO|AqFv9yvcNtA~FZBBy;sTCOAJGxR&6tAYA^EJu1QFvwaRhYj#(t(~#KM+Tq9q}%SE-2Z;{)Tse0`N&@AYrS4f^%JCcPMI z=B+`m^YSNh{7IUpGapRngX&w%^O5mvLV1+C)y#s)+znAdEuDr!|c*;W^{R+&umw?^~vR{Uc(B}?yug&;Eb^`&x-$L zeh|$8>(0Ol>ox9c#FYhL;PiZtxoqIj_vz*v#(Zm2%XE4B?dmI4A!UQUN|@=P{u#NU zOO`JB(?7@~hTMCWf*^YFgN^LAMP6da;xAk#BwY#)wDtFVx#MuBhU1PC=d7gEMu8uq z{PTBT9P#{xy)(A{Y+AZ+eN+sc>`?6VW5?~?|JJ4|zK^Uve7JNd3V|>u;#Z0?hiBRD z0wsC+^!do&>hsX7=)k5tknAs1y75)d8LK%pcxvFe!K))vSO$T98r`8a6x`*s@fI} z`!pyaVS|A#TJOo2mLW)lw{#EAwHam`!6_RmPiu#U&bP2WCCHTkc zQz`~##7R*$l}@Ty(fi;(6s?jft{?4ViO9?ASFc~g>sPwWe>|zYVdkl;7%|17!Qbvn z$t|$}48>H@mz8gL0>wEJ17Mo}MZ5Js!jIOr6!idy@;wTqr1eD!Dazpd4B+sipM(gH%4d+^UgIoY{acf zMRXw#I)b*9eJ#)?s<)17k?(ABl=AnUFKO2eN2B23(1|bm&_g`o4`NNN4Qyb)dlqaM zyC81ipxCmXNqB|fBj`Lf+u-7u2|}B=;G)^;*CI4#VJn1&;GDX?CK0BBpEvBiR=h{( z^S@0XxdfmK=(RPPHy9lLp`IPH_^OgYCp9UR703S&tEF36M@oP(m2P)nBGS9`g)1%jT_Gf3wO8OttMva-n?{bDuw%_XTHm{ZFV z7q{&Nc|Nz90^jw<_*ifLw4hfcS)@v_2P_~z5dr%07@*}k!+a8^MR;W8-{A&4 znK$>rW6Av8@5xsG+9BsS%dU}x5J70iR%dP96_pQOU*}{vuI~m(ib(O2pyXzY!;Snd zY2N|LLXr4E5K$6zuGaJ3>|-x4ExchPM&2^x^vWb(N>PjN14l{nF7}<@4E?KdV%-=I zH%@7QgB4jOv&p;PL2W18duY>(`(an7H)WFU9#`*_nANLPy)z-D1Ouqetkq#vRY`{fldHmx7Z>bn7Wh@Ar(^c9x*VKY_UyJy)z9e-OP9Q zg_i0CFXRz9cUv`=FX;gqG92U*KlWk!GTd0P?vo?>c|`gxk>p})(};$JzJGG}uJ6hd z=h(j7hJakfgtpJEwQ#6LzqSPDylPbYPx5;m_F`ZN+WKz0 zno?MPBkwub%jG;Z`5Q2BNb6BJH^6>VE#oK#!B^z4sb{Fs)couT`$0cQBPy*yA_xR+T zUGev2R;vS8f~O${;Zpz_Wz;QXVJ7Xm`A2VzB{WvZiSFpP-#s#Zb)pQB0ahDMu|ghnUVp>4tkvgOw>)=#;-Lc#*EkMg(^?9l zni2|Z=)_ag;_)7XzA5;hu*a!VY1RF#6GvwwN)$^hA8VqC)*e*nG z&;8?C{b*G!b*z+kFT0O3w85jWA#QSybL(H};AUgkwBhWVxnnA(f)#mb>iShr#(aQ? zuw&#+z0Khh(uwq?sH?o0--AZMP0V5u;Od-}F5eUO)=a78lM*sP4D$;16K=9mgn`f7 z7Si8FbYgegyI3XwMI4H%oGeVNO#L^H+VKbJqAyX1MBuaQ7Yr%mf{#R2P1!d^F9kng zyf|~+OBmd9Cw5Vk_dI#$=Wx!Zl2Cq5rq(ySR9l{(kMfi$u5e%<3J+Rut3Jz%M|xyo zM?W3?!}KvO<}ANT+V~QDM2C<)?`t(;XYsIcErTq90=pO6YW@C7K_CT~f=(NcU-DE1pd_e<_7;TjyTT=J&v+Gudr0h`gF@OHR)6d|*?`FkuDK6(U!Vc7Xx;f%;E5 zYSgvG-b``SnkRJ#ME*~uxH{eTiCt-#)Uf7_ONpFF%(;(JECTR%#n-hu>Dht6K+U$=3=s{E=Fg#su*tE{FrOVf`+&`C7$t_Re3* zA0UCrc?yexXddO8G;4m-Yn~FdP0!)8BLe~m?i(@I>UUY60_xhih|i;hFX+DKB&V_A zl>oyc* zH7Ki2+;jU$9!=3+`$v~`X7LNIpw$2_|#QB4;>uIk_Li7$m)1j z{sv(KaFw|q&fu2XjG<6m2x7j?#p>nQxl&U=KXO~{Pl?>~vQdF|G5HT4?(kQ`kpoA8 zSk)!P0lrB~uu7^D%Wh2m(Ni=Jpd3d6T+HJU# zBfXre%D=bp>-_~Z896aFMjJP}_LO`$V>b_>VP-Ejz%#P+BG>=SIF7{dU3tMw-i>r+g)p|{`Gh4L_+RN#NWGacgvKizc*_ji4G^4bh&u_n8dsF{W z6>*#6R;lGV1IYlv8|=L%E;L(_l3d=YE-AaP*y_Ni_e$uuH}?5j7#bcxbq_xLP*Jg1 z9#i*?z1*NE4vE41S9DRxaN$+})pLp}5{uRTbwkxR%CD!j3LgVObB`tE@P`fT!$4X= zDPl<#MrjWJNy4???HIOvcCO7gzl3XA)7 zus_kTqNOYC4VV8#7kU@ov3!FpEe^Biovgk1?{>Q)pa_R=v~-l);niN1^S^&T_CK`O zgJP@iq6xfHD_buub7{3)CjAsU)3*Nc1Yh=6x(*;prGmc-Sa$?;{fF1DN6TV(pbW3j zX2e4)N1{r1J)7dRS2BN;TSt@x%vc-xR3PvQVaSt~sDF&qLSM8$ZbosgtAjU)>FJ~! zCy_R3mjC?Pw{Bka{1m2THTFhlKG2QZbi~_cpX!ie0Qw5uZL7lUma`VfzD9p~I)2UP zH@*a%&VZ53!wh*{c18^fy_nL_jJ{5}hSc;w1Zfv;hq(LqeqAp@D$OQu zB=qV9-RUr)caiU9TeQ~rGcbWG6&vOEJoF>GuFP70Xl1WCdbY8zNs&K$y9o46edR<# zXM~g+)JlaO*1(x1IH zUV*`NP8)G8R268w=Z=oa4bbi@EyPtO!{HwCB<>Q3iCc9J^|_OrdhN2r9Pri!!w_2k zC)PWIAlE%mOlsK&lF+U@$FC*x-u#1m{H^AW zhM}!5f;5Ercu`uH=m|mc4<=oZKZXBXBpxGtIxTS`B9-^9iPuw(+8@4r8 zRD3%yzpKOhUO*I3mS!+2j#HTau`O_xzh)Gf&q(k#ql%0jRd*yL>3i?KiSq8@Ns}`z z?KkY=^92||*6TamTOeq!QB7aoL`#6COiO+w3}C=>F6|=wdGojJ!z2`EG4CnnyiyK= z+EZxrXUY(%9&W_|?tq;N3t2-Bn--NuYfg&XTx2F956?#K(buOLpFz1?iCOi9VS9Ls zmX$*8=KbopNh|f*6~&R&I+FK}1$_phc1=n#DtY|yF@+mfw<<5Eh`J#h-u67H+OQiW zQ?6*Cy!-WpZ@qi3IXc^tu>b^pTd7j<{hq&JhBk5{cjYdIoHv>@%id{VY1tK)gN&@P zSoSol&gR41#%5f=7V$^^s2eT)D}+~Ni6o2YAqBG>GsB5{<4#wSc6?Fhl*W5AuKuZo-`XNRTDwuOO z;^!$TZecH_^Yr%`rz38P{FTYVz0Iu1e2^2=R*IvxRd*9)kQj}MrY4RYWZT+){c{q(-4+MD>J;rf1_@r@b3oeuZH+$D?wbrCiIRCz@M@UaEPxjyLjt7$XiKgXesJ zAw4}i^MS#BSJe#hy%{2y%q&cY85Dd&HUz0*R2VlIJRcP!mcU%;a{`hbV?m^%U0Ue6 z9#6N1&iSk7S5I%~TDk6p3N&qq@Zdu>2iElakv=NF(g*+UBUSMRY^&2h=da9jWhdMc z4Z9L)+xy?xQTO>94^fhLX6~P-(kbnO)ZW{-3#VD)w;nZw{x$#4rZl~sl0G!sEpy;-Yr$(weChlZQ9J5X`K>D96z$4q zf8yu|=L4I-wSK}C;QQk+)!DeAk3vS37AJ0$mjwleY6vgi4N3e7+qsHN@P{SyCxBH=FgAl(?YBV$=jXM`Np(X(C&mGDATWZ3=T1jz?(@p~}6O+m9fEjFI*4p3~;4?Btj7 z?IK|DCvH=Ikd58$=|Cof@${@(r^W}z@y+a=G{P~dqM_MM{Zaeb<>5kF;l&PyZ=YYg zJ$H}rOCB3fStius@hwrj1m#i@Wc%YZXwqG-8(dKhb!zzGc~~%`q=n$X?OxI1oYYwx zHPHNOnxRT3Ump{>+etWBs%KL+Dq|d(?M~kVw}NnX$fgZbb()~GAd6_I4)dA6J^hd4 z1&~7rI?1D7u0+cm(<+<19IpO=qqp1dlc9cj-_=KDaXZlf&U>6SHD(PDazw8CqkK1) z-VjPkKc5u(W47-gKxu(tx^ZM%rnc}^pG`>RJ08Ckx{k@jokaf~7#}QTN(aeBJde%x z)hE?Ok_HnzWJ{G(ShV#MJaM={lF36(riF$zsP_^pU6y-(cI)4{{;BQS!fYZCP(0>{ zL#UB;5*#=9hC$JU9FVStVY5d<2b9)v^-`Vh780qp(P!9pEW>e&Xgve1RBBl-O-~$g zG3I+GBMoC`;$dumlU5NRO|CxVr7L73#*;}~ZH{5Sc$Q_#pS0hF=gVr?^Jjf5O_TEY z8ofnal$HBiZfuUp%ldGIq^uU+GBXF&1PtbQ99&Yqc22Oy(=K;U6}Vd9@p}kocB{f}>{OdM4FXsg80Q=_2al970o$(Oal0 zwdE5&AJBi4)TP@lV%{pnhb<)n0cO5!7saARpJ=90jZ_v(buG)Iv|{ecdis-q9DV-5 zehR)RS7#QgiFy5#j5;Z!L*O`3zd4>keY*z%D~}Zs@=xA9;x3ru2u$38UBQrl48FLF+ZZa+x9q=WeB;*Kqwv z9A&tBe^|TWkEnAp!BoZLo|y;;C4pab%_FTdzHT-Ccy%G1_uEqF4Xnp2Za%A`U>n2T za26I1DUnhFBBP&d1~ry8+xf@7m3r4UI?^$bboyLEscv`Wq zq>;8rWb;|#_LkEs$DmbH`pqn>A91Pf=N=0^CNCviDRvAUviOd1SGYG56)@gbE7jWk z4kBB#{iuoQi5aaoF1r6Sy;Y_s3~&(5p)>wr4sb6*+Sbv$`!&Udj@e;k0fcrcD-pE6 zu>P|9NBNa|{tr(a!Fh+`KrH&y=I>v&@?CFIg|@`WMvis^s^G)ee;Daz!(qNg_k?1e7M z8~s35-dU@Y+hBIU5)rRxBIbmzqvpG1QCx$rK3f$e;bYgqfafZI)pwJD~rjt{vO za-Q%gv1$-sVVST%Y=jx{;Tp_QDeQ6wKr_hitN^RzEo%JiJH170<7$uXx9Y{L0B1c~~CdO)_=F zEGf1iA;iG-f^#BA8XR=~+hy%W*HCUbb8BPSiqnbSYl+VrP$6D@+WsZ2-lJlCHLU=1 zW>xEs>+hd*h()1GDJaWpnA@4f{{i2Y44;@Qk5=<-Th)xr8GBw-0 zJt~5#rt9AA`&^Ea#4{N*>7SDyaV>bDpZl7+w^~>XGO)b+_F@rjNIlN8g@7z-6gYjeZl4>iCbBybm^YIFG2U zCYqaUu4BI|AlX8+xr01rbxGZ5>DOwqTy6f7NAL3J;UL3+_XZ`AX-%W4PnXmfo=VyL zTCGt3uzySK707vfYrx5C>KIQhSp2!zA@-xEE8Vax zsF8}$kB!x(9UGMdJk{L5Zttu4^cm&*u~p|nNAssX@9+nD=`hK42;>#Ai?0V~>$6Gu zeh8Gf@AjRYbz$WsUcQU~E+O~Avg#`HBy~{rp8+7mT2a{KeCs$~F%89gPo$r;MS14f zFgnhqc~bm??&?;kF$Zm^5O}T>Eut*N3RhF$Lzw@SsXlVN1?N6{=nrjlo(%?yD?`cBZ5*Vs}xk zn@uVS($f;t4jn5qb`QusA)3G|WH^}>I8UCHSaT~ic1fXotZe_{9g`aH=AQu}2L1UW z9XJXJC0^0WQ1vs^G2 z%G;&`NHhJ`V#8>T&O^Xjc{KH}LanGUKV1G*Ia~j)b&Tiq%vgsE`=IB2M%=k4fP@^B zf#T7ykoh%3ftnLF4=a)25BRO(1}8lD>iL1hJf$ob}}w0FDl<5 z`!o^O;2vyYn2qzFUFAJb*MKm4{%b!oI}SeoStxp$wvzeqqX~U39buL_uS<2+Q5^v4 zR!6&weRVo~mb%^)>#+O&TmEEy;JzkTWc%%*(N?+39tPyLOih^2`_ac)^y`MW5P+D7`^iYCM-rqN{0%G`vK)}NX^($c zz{bXGS+5zR6m=7=*!|w1jz|pr9mX`XX3C*7u<`@%mReY@_Z!54T{eoaOzUIc$w#lw z`(+0^#WUY&l;->5b5f+^q`tglHGhX0P3pA~;#&K4ETRdzdpZD661pB$ZIpDkng}%9%iuN(W+$i8UX`&Zej%-Dg`b&`-aHhLAgvO>+zL9E z-pN!1$t}_zh+bz^gZ@w!Ld{`>nN0Zsc=OofjF2mfMQp~`LeT8FyE($6+YjN>*$PZ~ zoCK>PyKL5izz1r|uS_2R8_*m4b3>1mJ**1m1g#iW+ig$!%TVVlEk+Y%_eXPkgJWK` zD)Ugu`o)J6^Z%v3JLqoxp{eyO6Z;ClY*KZuc1mkis~2My>OuHp##0^G0LZWIP_Ic7 zO75iz^ zrC(XZICa2d)~4nC1&)N*l}*i;`iGX^+|qtiYb5@YY0c_*+wAKy^?P)>!@o6w)%{d9 z*-dCr)`%>jJ>j#XxH!=~Xwx-Mo^gn=NUqT1dn0@$Rf(-mM`I0Dj4~s^{`yM>%1mf! zA1qmWGYc1(j^(6RV8chXj>B|{z};d@XrQzddH2=Jrall9`LGx1Y_%_;?s2p3ItH&X zd?*F}K3D(5zhp0vP!LXX>7Rq(v-j!FC3ipbZK${8Pqtfl{~}dp2iI*T`l|4aP?lMt z{BPZ^HrH3az$ef($Ng~X-33@HTSv`}yW8)65SN+P8^kAh8(=4;yZ^cf0{V&et)x$GtLlVT?QVMpl{t1< zsEolm9A=DfjpRgRsvHYny<4;qkP5IGcUBTnZ_9^1{m?b=fS~t;N~tEUZ@rAg1D&B6 zF>NB^218{t8)oy91dWwJN?Qt~?Ff5uZk4i-pK7DAi!>j0-BI2)DXH)Qs9?&7AF4sP zRu~~cGW+RM7GDslqkps9Sn;)iPQgsf#LFE~D8&P-Oh~Nz$3f%M-6d@7`Z*u@ZxRbf zLyTiRjssU)Mn{gBhLRt&)mjbNyN#A}rrj}WmDKH(Z#U!%U%(OxMQ^i^hPso@$HVSArE?yk@~9C? zn;e(NUs7LVNH7j8&_k3h56&r)(*bh2R-zwoqu(E#5^i^h5TMXPEBUd?^f07ru?Kk5 zwZyanI~jm6=!_Ly&f1+frX}jq>NM*;9-W(Q@_qJucU&so?Z6DBKyh)CU*cTLP;tRqE|DpONZd5$#$d~y z1eH|O@F4BgEf?FN-gR`+y>8!?N94(T&2-FqojX0pk)OIkQu#-->W!bpKH@z76e z1!ZgqlwKpvH_mo#tWB72B!hw&z$S_Y+0DIk)tKHCpe^)xsKn zB%xdIsoN;-mxK5j7f)qZ$V|nPtTncr23#a=GJUPCdy`l@^9MPhUMnJ7MH!lzPm`bA zCZ+ofI|C~=U{5{cS}vYVtA*HCY~}7c{I~q0okuT%lkNG2 zDiDTQHBY_gvFY0-cb?LlS)6((%XV(S*P0oVh1}MA!1+vE=OyMI>;NBLUR;LM&yLQh zcoK{YoUD`#QaE>_o+Q5hjcMol>^V(=SF>KZ8^rsG;?YZO+23bmBLzR5bzu3?f*V+g zixvsSUcC!w)mB|c*(D^39O~c7Nt5T1T?^=CO!7$Pera*G4yhT!nVcwmR0I-Y7^EJW zeY!t|grco9oL+qQPRXXEiN9pd{RzW5RE`B+%^y_(^5gU7@dkjuRze5)R>%7Hy?1)} zQC}Q_6RSlbOB1fH_g10%<2i{N2**WYC7?>_ATUHs&E;#k08GBV+Nk`&Ync4Gd9>M_ zZ--xW$W?A{$|7}t43e?xVf2|Y^Vh)x*-h}Ui$wjtBO!VU(t zg=fA+rT_K^=*ZM7h6QE!6gZq|c40g;%nV-B75TU=f}MXvC2EvRZ#L}fc%{z2d8Qy3 zzv3($D3z$oh>frR2OmB#5Ap5}Gh+R_=h#xSYn8zC+LqT?!WXxFuK{!|J>Oih<-nxz zH`xg3Z`5=(7irW(LqpA?NA1iN-Q!mBV{orB3!Dg10}n;1i=RwSN-8VWHnI+raM^j* zVV+nW8T9Q;S;z6ct@T1EcCtO&u_f^s7tIuZqR9%XSH<%=Znq>6BL2|Opbedh%*jTH z>Qr~m8-r#cLcAV1msA&d(c;=^jw>e_N^taZ7q*4Rq{#ze8Ye?HYs_9_W8Dyq-pvh=QZeMfP; zaTI#>ipZZUsUf%npLX5osf|Ob_h~If{vlr{X#_saApkS^Qz2RW@x@^E{h{f9o$GqA zSDO2W#y2CCVmPmscx&E0+kjjff6OL1G5kJ7q)M7A`Hh3Hu$b(~r9xE}r81-Q6OTT= zwbA3Us2Qzgi`-h?YH7H^T%W6KeR;ISkhM$&v>2-Vd3*Kg)Go;_yQ(o**tgJ|&GCCO zSYy3s#nQV@vhn^f4YBPi_w2zHfo8GuIu%Nw40GD0UjccIQS6;Rgefh-3FWhn;|aU7 zo%87oMVSg6@v`KULcslUpk(i?+$0rRfN(QUXgDsSKT>4Wu~kX%lt+q1VqszBN@nns0u{`Pz38LLt26%_d-pMh&Q``K@s$CPFLpuKE; z%@d;y!!m!?%}*1lc7v%e?n8k+=Jib1(b41mdR!XskHN^7OQP>)s=cW?(~4>KNIZW#j>?AiO@Rn%vifPxPXsgWzK43W=S&$UvT7cmx60A*E`EH$_9J zvddI(y6<+1L{GrL>nMamHD-0`c5PSk z?fAm>k z`*pH;ioR#Rrq|!`YyNdC(~?!*rJPPBB7(>DxKG&};dDe9ckOuY=m|bL_f6h6~8z;8DKvLhlRGsMx;b(;N zQK^`&LDzS+!LkU;Ta;u??br8$4OD0i?rhx8e`hMY2*JVuzcaR+DwE z_Yq?Eh?VRbH?Sik+tl9iR{q$pR~gNEsv|W{jf`<`s6XTIyGV=AF+p)cJnRlv_brLQ zII3MzP6Zb7voYk{TDO+#D{{vlTgP zmPOtWc`QthUed|#DrfB!pqn&GO3k)AmD=Pk-$kZ1oM;A%j7hD3o6Ke33fdRhdR`Op zjO;H$etRbK^)mgID2Z9~DPJsEq#RIT;op*qZfH9S=_IN3>Ouyv0$BT$W(gq+>2vK1 zs070}YQ`>TmJO^b@W~ARecberqAJQL#)ZvXzbv$P9LdIHY79)>ANIvZvCb=T@Ua4E zI3~k7{+s!W_F9}ZIM93x)`A?{$$rcn0B?BQ$>XuT$5FKazo`v4_Z;)L6PeO|;UCAf za(WL#uvqd!ND1!xrT@tLHi*clA469^a^^KS+vaxUHE8A~1UbW@rRea<5c?h>jV0=h zR~;H~NyG1A*H;B($7v!D&K4%eA+JT>y!V6uotsB1ocmH2MRR#JH zSLPu{2WZrL+Z4fNThp7l1EA%zBO*6%8r`>llo0#h)s_?6TI;OIhlP1G(1MlP#?5^~ zQO+xrS;4B)H;@JkQqAvZyCHWX`d5c+os-AyW5fB!aM5*b+s5#}hM|0aI>poH}e))LwFP~h-Zot=Ex{DI7 z2h(+kMs!*BK1TmvNGebjFy9CWZVfMp+b|GF0b#=9V&&3k1na~#hQi-6mgPsI|0J_9FBtbn!+!zJ!X$o?*cu~wVnc} z4)=OLk*ssFkN#y6K$g-s*=C!ATm`%`)T(Z9O}`g&gcO5Yjky1Z2FHzI2bThu8k`@+ zV`R3bhF%|ySDNzUqmDi3^x_a0a=LLlzyk}LWJDn&FzyDXU9L0y1 z1X@HI*JmBUvM9eYKJCuzslTmw`*gjVUmrX|om>O?xO9iKkPw#C1DHS@U>Az!KXwzC zx#c-FL)0f)@BoAR2N3o#*uR}N;WA(Min#N4k0B2Xn6D<+t_)AiO(9cqc-^vu_`v^+ z`kq&xPHyA`>5u>p2)p8$ee9mtIBFZ^P53sp997^Bnmb-W0Pe}IQi1)Y5M5`Qoe&|lORebk3(MY&g71k&G@sY{;i%4X6IhV7&-4WY+Pu}wYRm)u zY;URz$z!>&YD|-$aM3*YJCfqz$Aj(8Y%UmiOh`*%WBvM3EbzBZQ=J2=jKX?WaoHZRMV z^mn~o%^d@)Ijj8a$2)Tf#hFc%q^9T7({NqL$X=A0fwAc|Ya0-hG`?oo9Lisbh!>c> z`zUy)a>xg@`}^b5hZm1OwM){gKxR7mH+`9#)7c_RY%fHVbjP8WuTXPl_^WsY*qbJo zlx+~Q^|7Ek`JWA|lk57&Nw+=uWh4^zyC+o}q%L@x`|`gV>F$SC$+GlE1>96yRwq^- zXI?OwO<+HG2Rr9O?5kw7%@NCOKl)8S$o-L&KN*_XK9?waaERuHl18(utf&t839@d` z^mg=F+@6k3;AfqyFgEHq-lJgby}&khJ^X!%Yl?vGGh}3+x8zQsb2@2D+&77R0pKxA zuVk&vH=ws276?Bu|MmO^0&6?}l7D|VHseJ2OBwCDl*CI(@Ii>~ZUDSsJ^IQ&qaCQ+ z@)KtxpfqDi?A@=c$Rgu^#m`M9*d(>c2RTIyS@n-9scJ2X@$nl@8kXsG}?RQT&Qo-l(+wXA9{l7g9 z-U?h$m7Wg#=CIP8x|4XS6|xwtJn$-kH%}p*3q$qOEaK|{`@5U*gJ}&XkEqWBY$A3ZyooWN`9~X9q5d1#wG_*~p;Z zk?8!_SKg)v+_HNvgzu8evA&Mi5NVAp_vAG&|1~9X^1ZLYUw&^|JIK!CB{FE;rvavn zL2#`;4N>Mm@s{thWh}wwgP?ot&F^DZN!8z&6E4Nhnm`IE#0ru5)?RqEjz(14aUm}K z$WEV|Wq)w^Z1Fxxp4`M@r-;Xl&ov5yj&{4TjMxrOYeJK0Hjl={)bC*5Ep5XP%qi8C7%pIF#1vz%_<;byG2BM zK=~eb?0tzR!fXzu>AX|r5I2@=52D(8J<2=wxPhE8rwlULPmgtfjw?q;dChHRz*fA} zSyAZIs5PW4;WfVt;kWhS-on4ZY@mEta17sbj6Qi(-!KatQT!3%&whr6`_Xy@%a+wr zdzf3T_LF-m^hd^frh}Hu!oBBVxpHAXU-aH)8~15;zhe6d_u{N3c(u<5wwF@1v4fW7 zHl^Qj10PCc2T}os8~VfD#4o=}_rfH}^@}__`}ot2^7CeMCf{xq#yQUXDV#4URUtYL zWQ~K%o>l@g+9znPWpzZ6>-3&O zlVb!-K^a?lBmF@){%8+KjO;&@fMJ1`h(Q=BLPOfO`Cc7%slX*t}QvKtQ@Zf`Qhs*cKc`OIkK3NUA z6H2#*{M)jEngZRML=IW9j~gcgD1YV~$62Ve{$h31Q16GP$50(nWvF8t_Gx`2_74Wrh1?AEwi&p6uo>g?s(9!_P?!MHqs7Tk7dQ)hCbU&5@>QqSY|nM zM<`f2ND)F7j99&S6E%-hHrxv|6E+;4I4Gj38e$HGuYlxde*u&KpbcA4RYK$<_mmei z_ap=qI`Nw^QCqF;&QYD=A|MTWo#OHIv_k=KLh3`S2j}lbp%TqNYwf|QdirKTq3l5k zhK07WrN$F6^Q-cUu*|jQwVgrwNUYmeY@scEF;l^F$>+eQcj@sgsAjzA4?Js#_}(c; zLMx#Cia`B#I@+ZNrTg_>i{27{+$GklGH=-z(R|}hKk!bGL)s)z=545R+a){BZO{Bb zhl=UU`*nwZvPEQICQmvgy3INg$)66_zb}ABZT)H2nNPZZfcMItq&(6zLmnKz(pBpT z8FgnrX&B@qL_(|lC598x3^<-D`OkW}nUI=(Oe&hxHmql~7xW%t6;Ec>7Fy*KdAzQ87S%NFmkK(vt?~M78XuzydxWqTfG|fql>Lh>CM96AgB1J8+aP6 z&DHSnX|*M$@=$rC4wpZB9WJmxHnR)>dd~mYQAMb}XYlXLnR(j3BOQNGOI4=sB9Eiv za(EcJ2j*5KuH&`IjH$;CDWb^5J=AeXOwEbo&HyJ`KaXvC_oTYw4e%F_wb&b@9?gL1 zQH|(C)GyZIJ7I4)9Y$?->BJG_%8JH)M2MkC>b69n)PIqM2L%$x`)xd8uWXM@u{|dn z;YV}-{wM3b!`b@Z_+dp`QEdrrtriipC{kN%kJu|_Yn9m447F9QQpAkCD=}-97`0bW zwW@^LYD7`9#*==&zw38B*Ylj~{E?iTbD#J7e&6$z`1WD-yQz%uKJ=wb{#|1;UZZ@2@FRyd_MJtn?)cxJMJ3p&Xrt^$KJL4yAWm}S=1w`3uT6Mp}owvV`CTElg&;0 zwS@EhkT8nNoVwF3>MOK)L-kqEOB%i-X-CGZc&GXP2&1CNV*U-=YQAlCyfOMhd7W#PmsRL8D#}eUaa8Bg1$K>d zCHHXl4X051zKCC&Uq4Pv(d0}gAoVGesE6kd+)Y8xQDUK(9dmim0?me)QQxcqQ|fu3 zG>{>RYw?Qf)S!*@9ZOYk^3d}Uu?K_S)0P(8j^KK=y8BkL%Vxu)`^!x6g>A(>b!3r? zv8E1*y`C1465X@yW@&WuU1cKj7lHHX;c0F{J?7|PZ0fVUV^5ilp{qtF+=E7h%**N^?QQW*`~Da!u4h8$`-Qkm zkynJY$oRjSk@zUum99Ms{f+bD>o*(AoW1Y=43mLR$Y^xiHjF)D)=b?p$0)|tS{`)% zNEo|xni#lbZqWiU&Cbalx3Al+owb~K|BlYO%C=i=JT&Lz3N~pY`#q%qJ-YN?j^~xo zkD)yuTAbzIw-D-GHV9?Ft%`y;*KE+32( zcO!=8=dI(>AKF&~MM~oO==3B%E*`0b#M>AZl%qF7VzeZ@**-N<2cut2zaSRpf3&o7 zMWnt-6p*{%q~2zd_*E|nYQP1vsEGU>rIi^i_50bhyYZ#cu5y2g<>S`L!d6=F`U0f4 z?`~;T#ibS#2f9yen&I;Ggem;t#WJH}ZiP0?`o81avz&>qW>$R{Itw>XFT{?e)=TF% z>Y%8ux6gpRxrNiyPbxmV-hJ2gDkxWKj5Uk>QYItj84`B~-?C&gbYgxt@nLsdZ&cKk z(K^}aE(A90*&%%S$HM6Md1%F%%wYN6S>p>xu&&c((B-`LiRsmFH|N>WkA9Weu8r!m zowiyO`qjeioSEUP_MG51StrJ`5X5Oy&fWS;?E~B#uq<+L<-Y&!x&QasnNj=&!`HVZ zyN48`CsUR*#KQT{n{V~^Yj93?WM6R&STp8O(QY03?bPSLI!`NPWIWbau~7v0&YCQX z4%6IOXna25ovTMI4as=JG{ruBu8(;~QmQ&|S`hg+n792IKBvgqs<5Maht{-0B#TCU z`mgJ8I}b-=1t) zBvo8O3UUmp9UA%VraTuN-A)EKMGlr%`&vIIRlYnqeB$hJJoYjeox|s?&``B`wu>r$ zbb6^pp9*LE^{Gp&tbO#Cd+C{fVl`GI=+wPx%A~KV@w1DVT;_RWz!`ieC#i9StR?I3 z)yq9ep0~s{0}#^nE@10Liun3367{4>!0Ir8G<>etc5 zZK0!%*|z2Q)!Wp@S$*Ike@bwGAjyV zG5cEga;foK4rMYh8W{bn{iAqr+*;_-dRkC@9w;~Ch~=e5%pv9dBMBB}l{K>fE&x=; zjO9=*jhZuyLT0R1MC5Wq{PCfDY|w|s($JNy3jWWwurU!z4H1LPJ{>UXy-V@$T z&434V!uP7FSN-zJ)SaUV4r|6RqBpj<3+FPU2<0?nb zAHrbTD~T5}lYuO!XU9JerdsQruPy?OR!$eEE*kSZJRb&(cnlj=eQX>-^uS;~Wr~a5 zxl*H<^Lnv>IE;>ROTK<557*6tZ@VlkS<;-^*NMuJEOhgrRg%iT#^#r;Uj6d7NL0y23hw~pdM(|li@n$vKZ@GndmWhJ3G5+XHJ`A??%aEWAE`K6=upb+wG^!IO|dW;ls=N_T_J9 zw*}i%aJPo8YySQ;Wo)x24BCGbpW2`Bx^1REK2H0tbLac`Tuv^{aZ$d}3je6B_UCU{ zc4fR5CmRm#h?)#}9m#yoxwN^lAdOHCI=tn)@l2`z)$KobB%fbZ?SlMn9*_P#K03hf z9rpT;&ije~_P^O_G12QaPOG&7n3Y)G9zCv9sCe}|LKqQPCH3=i&i=gbmwRB)-h#PL z;S=u3gPV|b8;?hLyh8knipMrXYB1o*xx;bS>gV=Qys zNcN9Wu@cOFTI}C9bH!FBnIE)XCIrilRprJ&gC*0cE=7V;g6LFM73+Uh$e`c$Dqa=q zVbA2xF3ql-+k$L`Pi+)~thl=yUA;eT=48~}R3Ymo)`c96F>>@-XdYh0dsq1_Hx%4y zxEwXLjuYJe@T>hPZNx(sJ6?=v(F;nX`nlTWw&Z;8g(`+DD%T1;-JefHuzoK zYP-3g#6e++G|iJU=Zm0&B=R@>edBx6s1NqzbtOE3QM9p*GOqD|JUgU^M`+jKT=6jA zZM(;siH~x-4tIC|exTtfj+By76TuzVNEa1T@B}Q9s~euZZJ!T#DG9W-bm?uzU!GJ;`v(9(I3{8pKE&C}pw| z-*CE?`{H&mL%|bG&xdPl3(HYgY-euxpc4xWV<7Qg;rPAQUv&#rnw#_);sb3=HLGpe ze_ksLUE;cqu*#_Ef66GDa7wO8oulm;1QSLyb?lz2vS^c&?(X z`1!t^*pTjcpm@AR{PDx-h8zuSAj9J)xtuNQ3y*TnK@Cy}&khQ7W7~A%6v}d2973)6S9Xyt_7>4b__C$(g}Ic)1iuw*Q?pIC!m|1*IVEd+L+JuGcBLM*F0~KP^2c`;hghtjDa?Q?%FBc=+8ZRu~ zjE}@3{`y?_N{98jj15mIke_u++p9QWzqA`2n#X4EvmOk|=ZB}^?gS*h!Azg#9=Bd9 z%iY}FUw-iSWG!P_d$)b18|d(!_;%1U_w}hr!p-)_glElm%X|l2U3Hf#ub-u{z4=&* zi2ryw{&`^KR0i7I@?p(_mty_T$oi@il))KybcVQGv1_?3v{=6LXa4NxgWd55c5@QX zyw|tR%imjkyrZZx(gXygKHK;b*8PVSZXOtP)i=gQ-PIuJ&ip0_z_@jcF8}cKd4H}< zh6fX5`1j@Lqt@C74{P1+Z1a?*bId)2McQu!=LoLzrHS8K@WfrUzKO}W!%sPi3}>_Z z_`ZigcbD7yNJPfZEYOISXY;QVHv~TPE;XFykIHFV;bQdBb}%OJ%|fM zcc;U}ZRUeza@kvs;Zj3E?ao{p&)Zba6*6{`10S_+Nhi$Ln9OQiz4TyL1cJ|kMIJ2D zpeQ0vMjjc>eL)MUfm&6LukO9nN3Zlg3Y63SL3J?0ViD!{SdE7rBneu#^gG4s^8|8$ zl_uX@znuP45S%;teaFcqHzxSuI?ZU(%2&@b_?&a!GSp{w@yel_rgZ~h(tp7(_w({f zY;pI`tTfGuid}(7gUVCf&WZ18`KsMPh3AE^0=MGIO^#Fljcf z1YRZm^4!hI?ltK?O&l25tc7%V)9e=tng)4qnH;={+ntI8j*Mh5&#Pdwn)<g|yIcK*o#3i=$D2%ZbjcKeGtO*)7 zO3(gQ*7EyZo3nsAi>+N4Z`n=>z_&i+JeH!9P z&6d|Xb&J;)Hff9Wdvu5Eg?Xm z3a8LN50|Ige+G~Ic~D{@Fa3-q(@jZ%R7DA$sqg6WJ@D7hFWV91r&om$XO`0@Vb9*m z90|>Onb+0_U73bKcDA+gzH1gPIwF56+djpcn9Ti<*Fd&Au{{Zl730Z$*B*$jr+cn6 z_Rw6(?C-?HLejwLg+*%pbZ^%V5rqhn7lig|-<+{`37d<(7D7T2yhIVxkBO26m4{VR zl#xc0#8iiU0hJfTlJa(v5|5JnJIY2p%1s@D=H)KUZ9kOhB0G0&7&@?OoBO6c$#O=i z3Uq(|M=Lyw~hlRV0jm4av7B z#h@Lp#c=1vFaS9-IJhp?FM@wxjb5QbNm=$`Fj(l_v-#kmtaX-C-fEd+-nOzH3l?*? z5noK%@+PJQQ?}c>E{;JjRT(W|FbmRR7Vj}tj-|f5WlPhG2Qlru?YxJp7PPB7$x-#&0C$y)Afa|X8c04plA*bL z$xx;*$Dr=3NF?F=6--^Tn;|wPs_^Ca0#`)#ORa{orR?KH96xN~9&FD-iXUdvvX!^#RYXmy#$b6ffSEkht2&HK z_zO*(RoFGF0>O~}Fkoj$bmy&WgPmV=s;l4rWWCw%N10%iZo#%9Ff4Aho}<>%NZJxg(7eszlCnNtV*smcx1T~;CP^GJ;9;2c& z&;VQms>$jlJ+&gD}bqJNkDxi|AB2{<3K2L-dutfE#1iQg?sO&weat?qT>@n=R!8JZjMY@R^5HD$mG5g%% z!FomD$w6^o3(W3?hFljC%KEx^kV5}m+BUtY-GLF*U7zJ{ z(!YNFAjkJ{>tSubuV+^8mr=eOQ90Vu>9#qv!{Y-h2VGgwclmA(C~K4E!1ZtMpMJw^ zVldU4THng{^hdMW@9m#k#Gnrr1R^ly6pTgBd7JBDhEu+nTE84eOjUf$$*!FrYIT0n zXz@1*;k22AgyiOz(d-(a?mL$WKeMRwj8G3)7mV~9UQ5O)r$VQ;vlZh8B^W;p z=LGzD{>JZMBf2C95|r;g0m<{NM~IPn5!a zQ}N1si_>UQIblrxh-qc})Af-<$NW8uw4ObKy_f#$BLS5^axgo0Nuz(Jv{MinV_;ep z)%NUo%BgSAuVFI#SwWS^fgN~IweuaCKMHCfFWCULPwW-;ff-*@paN(xi$}?g06U>h zVDP7F^iXoTkzDC7(mzaAn2V(M*Qgu8`)~`M^#s4gTLCvTf4^%SkQUG<*33+Yocx*G z&k?|28d6|}-`|+ozsbf)=iH1|RU!w<%c*jRE7Db03!m7WF*0@y#FC8Bj}#h%!PTF@ z;8gM~%f;K&A#?_olJu&U#UV&jsQ5KvWBc38tkraM)Uv#ZtdgBmxIKc$K$H$gtlUzitT67fa|H| zSyMn$%IOs-4XM8cCi% zXqv#9!H6lnk%$87MHMmfrZ8}`VhT{)$(Rf|a7YN(NB>GM=E&?d8`|9`IToLFxIfOZ zMEz-}B)K^nMdE2n)C!UtTe6Z~B)`7qO1Fs@Q4K2eXag5*ha*uqHdH!Un!JlWnZAFa zFa2O~J_UYWL*q6XLUGKLDvvdmJVX_cFhrG*Y$-~8>&`0pA^F>llyG1th&sStl&s_n zE7(9waESI6z*72`IBy;svStyfM?SNVh*OwFdsFf%_%V*@K0=UL++4>;<`>v_Dv+HX`3Cc92p_EY@jY zbs=P?IOs!*5}>`lKVp6u=#ur&>F$rsL57S2LihZi^eyhz_OkuXBe{Y4l9j*3N*97~23J!XlO%*tIgy5VP;}m+xUSAXg_Ko&O%Tk# z?L_Zcar?Ft11m?!3LVlIDF{%Y;5O#AOneuGxhI(g=5DvTIODSw5q+mz^X(+Ag)Cb>vY|tE3uz>G%ahYsTBi#M%F$7G&;r=y)kW!>*dSEl z@5uFPbS733*`oQ#pCy47R^r4}-nfgUz^_ zEPc$v_*H5P>8UCrp=z}emPx=@Y`?Q=1y#WyHC1VGcY}RjUsoxpmD+Vx9x*sr1oS|T zTdzXk*Gik;dSt>*WX_|yJ#Z?ph(ZpWw+x`l3!5Sbf`48|QvXiR@ea|JPGS9+)Nk## za`5G_=5E0ontWD6nlFGATYOx}1?_s%|jS z1)Mk`C&iXElBE^5Dy^26 zT1EsgWus5{sJ+=pM|Q~-ADRd@&`oxJSL>gY&vG2B(iSfxTi$$L)jMdA`~3Ozs@k=E zn&g+mKqwlVp*a361P3G;YU4KKfp3y~`sLevVH%38rxvFX;n^G6NR+6i(4tNa|4A3d z7~{qBJ3`;#_rowvfnVfhh}XTL%y?SeLNEIq-2&1j5i2JsJ30d0w+eeMBPDKneph1P zD+$ReKLEU169Z1nrqj8T5GiR4xNbsq?V7$WO`(@-Oy)O{0~mt8FzR1V=GbDnZnLJGI z4lQ{#ZU_LTjVV-qJC>yUA)`JaJ=x0;sHT|ING+ac|JCYR3*(QmmSrd9J~2RTgfSqW zygDS*z&;#E=XnZV*Kh(1RmJjRsVnMIV}O&4D3f7-VpW zk`Jq98?;FU$+HTR0))cgq4{t!2BAD~_`5t-VmopwPRk;D0IOheihN?ZF$kE)tCtG2 z;p9}N3$>;!MHiJq=^a#6DOs(o!OX@FB3^$n7hg8%cXki$>iELV#{p#OXNY5+ugk)i{*R2Bsaw7psItrMiu5v6Hazh0dO6p_xeQr zT30zocxoN}4V#458z-njbmyDrslBhW95o);%AO#=Q*aJ6t zTH&Xhb)E?8O%W2N@63{H+tf`hUu}B{yI_rcaioz_1F|l$8ws`HAih36SIjLHefR#& z&T~V|aofJbxYYhNqENwLhsWkc3a>NU#e(j}hv-1QkUXcbYa(#pOeMFn z)>8{FqU9tfC#&TnAq)Xt*_$v8Pl@W0xPDd$BUkjy%&&VuqLNw4Cj6S{|4KS2_!JsQ z18%Z{!)W+JXt89mT(;q4KzX(ZGKE(Z+!0@>gsW5UKVo9E2{T1j zBfsd&U27IycRYD;J$uzt)-;mbksZpVE5IU4BAI*bp3 zUYpEw$Iy<^5c1P~%<2IyOO!rA?-rS(kI9N2rJO3y_0Y9d);3@5+uE!qlB&;+iO+%v zze#xpU3(rjdi}2S@88Q@No~z{K2xv*KNZj|eo@S$mh12QVofav7N(7rV8gRTig&63 zNw|9NlNuXSQmBG!-mc`Rol0}j98RpHtSky84H)F83@l3bjdJDOWLUJxVF4 zK(Y~r4m`1GoR)b`9Np0g_G%hdy+}niR{Bq+JPITc;h=o`kO=aKJS%Ww0kVJ?S+RvA zNAq$5$rD0KK_PkeVFn?I$-MVLJgKafc`@<;Mht+S^2QDNR3K7`Hw@%pTWZT=!Uige zW=)CGrw+;M1{ZUqctSW09ibGY8E^`Dkg=h$O@b;nYY_lI&&k1-P?`z`(G^F7OaLG~ zBu6S;v}zcKYM5c334q6xDk?#UG@p^1)ZQdCCLC-V9TFWQkKsjg>V?^40PmBMzT;5^ zn8JZzq`V#=q?l9Q*aQFw$%965@Nk3+vUwD%=7TBH=|hpJ#`Gpg`50Dc0U9J%z=Pxo z3kS3ESPSZbD9M0)H2CL1|JxAW2on^C!2lkNoMa^C^`=lH(N?WXj5rd%pgFQu>?8)& z{(Yn@O2!dwKNOrjQeUB1j4B=9QfI>-X5mJ>i9o{tc6AX=HK1xrw(dA@WXcnULE_Ol z9Dc4NDaEYtYI9ayV7E1AlB1S?c%DGmgqn%q@mP(PZX|9>;G2R3DjEs{qlu&xI-&e_ z#7i-yy5va!REj;y#ZjP#s9J_=xFnEQHvWA}jQeM}cT;*QLCl*#3p!@MkHc-->Ixwc zP1rhK^kd)E`D6v#MV{CSF6|q<;BJ5k3PtQji-e+(|K@^0LZyn36F3~Eh&b?6!nUFk zObMnDH&{|sNGEVChVX6>L-_n*#!d1*r4NT2Xl*M%YbXJViZ7JoplW8HI2!#qH!&X( zL`!l1>Dla*%4EkoEha# zLrFKLtic_XIzIq)-IyE7j7vzdERVzYN0H-`Ds*}Nw#MMZA>WCUZ)o>~p2ObxB5f%|V+q}n`%7@>6@C01Gh{4AEZwczod_1BBF zUirMvYKr<^fg8ver*3qFufgH9T5wxD9zQYx-+{UAz)_Ol%!;}d;dp&CEmKVmCfZ-P zA~`i#>#ZJCT{t<%7Nco<9hS|@pYoVP&2$p%nFiJP$GHFhUSHv4I(ggSdEJ<)4+;${ zLaM>x_f3%MrU*6L|DDc(MZqBTnhnPYM+}BIl18Q|JbrHYa}-SL7ph+P|4p(seY|IG zl0YC#;&C1Lx|A)KDMNxYe$spgmLxg(f5<7tL?Wn>P@>s|DY#5hVldw$GI4Ql;ICdm z5I*%S-%3-O=aebu;uwh&ZftggSle_r;ZbfrvrWlF$bl#j1t2!7|IbXwRiRt}4deia z>9>Een{1kpxZ3zyC+xf7HG^EE2_x|kUGsbf%6I{0mc>KH;p}^c)@1LL(x1zTY zFj7O$#6>|qW6Cq@Kgs+bH<;GfbDu@9W+`2&lW||&Ywx)V%y~q^Rp39e;LVos!q2dK zaP-tj)bi?s;3B>6NS1WrV1G1}rb$da@^9np(}Pz$zke%kxjlZGy8q3BPT z@x*s^E&PgeR>aOe|AVt-pR!eWCkpU=RNN@ZjuJxvgG#hd$@zwy@I6NiwyK zs7B99+>7^2lm*EnZ|~p9FawyI3>H;AQra-8ae+`bycmdM$P_?&>SRsrWOev8pun)9 zj%&<9u}x9auc$oP%`$r`Ubgnt_Q(}LVczp{Ir)*n57(TuZA5G8DbY7H zznVyD4#fT#9>T<+CTY3Xocz)d1$EIQDgk|qWuuY?545CAylLLk>#m~dH(hoZ8FUjW zdrWY{Z=J-bU=FQ4ufOWwxYY+(YJ;cNN3z>DeJ`Wl6cw$T9PYt#;2qeKQZSAwS`Tw_ zxP&P9wTBbGkfqVnTXQF$YoyS)9efqNW_9L~R01CnJpUw_bQM}m*NYvAeXUuhpRN_>XZX=l zK({_1NNmw+d@93?<~k$9T=B zreM~{Uv|Rjr~pg^>&l?wA!MOQ45uT8adrtE-|*&YZ2kA3Stu-e0i$(!nw(r(@Af~>L?fA zSiQ+(nAsXF*+>HcWv4&2xO@H4a_A+~Qzec?8yB<*^<73NIT@ckMWw}tugh7J=dZuO z(8Gf*gf!S_*M#(=r{?=72fJH`$H#}M=NC3Mij-B~P$&cf1^q5$hQ(%V4)@^aoh5k9 z2?WDQmeR)FZx+=3kvjj9Q(jCIKi@FE z^?C)Ki<3aub@PM}$Lo~IZD>GSJ}Hr{R+5IuFeP3G1rr9?o|KGy^ugpeN>)2Xm+5Ar zP%twD;-A|=rAK(64<)~iI1taY#eY*=1t)t0I16(%w`p4_|LV$;RHf>q7?QMj0w%1H~XWaKPrys>VBYceiBT& z&GWF;A9y#!OT9RJ14w{Si9tuqR?OiuJs~JvQAP^~5PNArJb^ew?oa?dS z8m$yccovar2s3mQH_%)8D_&br{|2W@yuh%72` z)HE1|+k#8<)X?CF_xF$oLRlzmMqE=lc6heFd4AJSbIAmS$SfkP_RPOW+(&YGLSWoA z1(*`d6oCpiSoS?SJx&cxAizzr*g2@7jiM5M)AYL}Iud4-0)t_7I1T2bQ;#2uL41A_ zraT!&ypRF|V4|y?lnhvzgxAoG$b>24M$nF)$buX=vD&A;f?KzOH|C#XA^N?4tJp)7 z6(p430b`+@QBa3oGllEHee3|>gb~lIH4sRcC=^7oK*Aspgfa&6$DFXHgUTA{kE+09 z%35boo!-q5Bacs$22<}v^u?OwQ&zi`zo{B1Dt9ZIPr?ioi)O$R#3fr}%xugYq7gH~ z`Xgv)TpFzDq_FrIb%ZW{TE&C?$!l-}2ad0oTVo!-t;mCWFJjc_Ukee|$ZY;R^VGY0 zD$dON{UeH~NaNYz6hrGrQ z|AtqO4RDi$O`;+-Jc>+C(bO4>+cd+Xl43a5maIXI!Xu-12os9R8~x3gAxe(|J0F23 z54ap>-5Wi{*Wfm^5UD2O%(0Blq6Zpt4`~oS>rhO0-ZO@DWE_6#4{ikRYN>A7ZCE=z zT}{|5)gdxdi*P7k2BW#l@bok(MQe|*k#3|>s)-nMqOjPCh9gYr+M2DRg0eR_1zfLz z`Ud3;>#e_+EstxSawLY^(5LrX@!z9j@%VA`bKBz|D?j`iESomIvxJ)sPj+2{$GJ2Tl?k+2 z2pzIhBZA{ZZGXtu=8<6P`!SGoT|!;zvj%S1b^9g$yI(!*P9x%AI+|t>|Sf7bNz9jglL?6#2}6(UOIBYf6HI~(_SdY9Rd;t7z|G(@+3h70y3zO zYKDRw!1XtE3ZJAQd`*e0ZozR0!CttU^oa^=V)hzuu(cAcCmv4>t}y@P9TWxt6MlhH z^pwb>|D*RU>fLqnQgG6`Ab1c8GZ;h!tV=+--hH&mx6?G4dL>cxn5Z7$q@B$|0k>9k zI&K6%PuOxzb@VeRA#m>U?*7k;F6Ta9=+v;R^G95C_pPuUgs}h(w7t56oNJ< zY=910PyG+?N^qZGMlO-}rfO*rTYrMxrswXTogIC=H z-O-|;>90IN^x)q#r@S%YBCn-+%G$BTuZ@GQn8Ro4pMh+jUvqj+H!!^aZ02|NA@cjk zn}OHAu5O7dulC)r(MM89-yfS8ZO=bD4`?ypM4#Dd_XM7+o6$3BJ|7{>{usHtG+T)W zsZ<7u7_{E?Nw#6W5sJne zX$jQs9NmX|amjVBYdgJ^Ycf@IDHKSOzrQv?CaK9#riEl3cAckX;>MTq9mWpbb;xlcEn6jd|VK;2Y`6DboNj}cy=Nuwh>02hT>y8CgY_Vq=rF;zj0ftxf zcNgtP>*03^xT&9C!h3|RdoWToh(vcR8?TNBmu(l^mhu}@raQ5(oau+f(v|UO_k279 z-Tq@&g`ugMys1|Dht_lDPDL*pw8~ie*T@wZTT`+%=|LWF7uyFNR4uXev0Pd}yO(VST@;C=jNq+hCO1X&Vlg8h_O z37nz(5J^sZujm~Qg`VgJVWRPM9aE>C>1&((H+SY-Y_<#2D3f&WLtgRnXJOpsHd`|h z?2lQ~p~a=PD-6ocJ9BYO?k!25qv!m)1^9ZDdnB9O-#U`zm2hj43BE(qi-t?pa9eh(Z$F#D-($+#{)-ty*BJd;)|cd;x_ZaB^e%D9+(&A z4>FA@J$#Fweb;&=pVhC!nfKy4R=^osCgOL~TLTvM)cT!kkKp8*%j&EO{FQ-h(u13t zFKl1HvO*s))$wU4Ygr|O@h(%2g=S1JNv<>rt>YWrF6OW_pn4`oXS0mA8s-3DQf+Sa zc&bo7JR6sAAnKt0Jwu(mgMVn6mJ>wQ3o&19A_}nUeTmO4ef4DwVickGG*aF#h**AAt*>0q_){v4FV@})0F?b^J zs`|L=AaeO;#(1Ad_J`=@EWcTEvFMD^g@X&p@gl-d^sIn^V~~md=pv5wZAk#LO{fi| zAufEOpesJ7Rl<59*y8!&ArUshT+&dL5Ci0f*(e<^R{~+Xn+Ml>;D{;lnuNV1B-KJ7 zQlAkJi102?!?BNgKyrHj-=LCCnj5ZgYH{Y?@dr@3l{2~#fvY(hp7j#TkR#fWm1#h}OadQ*%93LV$~8Dsq~&RigBt4I)#*nn`GDmk?O=qMEK~P6CUG|m*h<(Y4$IO z%4}BqzeNA<2X&nv6RmJ~JxpC14becT7+K?_-{bsFR7`s zXw$H=lGQn4H*u^#F%KaoK?p3G-`+8M{vRMH>g8R&%I{>#`Hs^DsaAUf4u2NO3lZKV zfY=C@eW*qRoNxqUM<4`1VmBsU;+va^B~Zlp0{h9=2X6k0AFuCNhVZHKvDG5%%qc3v zCv`<&dLVW$V*H04a#@>nC2Tp15pyFbrUL!BHPseB8x6NQMU7&8ITk9oeL%L4|=MCY# z1X~U)!plue16+uJ&f#M)F=ZwAIJGh%j!bqRP_Hz|rl9g@vkZbj7z}f0VcfjH#0c9> zPLc>Lt`QR^_U7XL76~wJ9#2G8ziJUxjL3J%GBttHY%JEFCXHxk+2q-j=^l! zKx}bRT?vE=$H`_fgvi5i;_AQD!&KmLgR}kbsyyZ&7yHh?z~KlV`3V_gX#9Q&6f>1t z0OHZ8wUe+x5<{`9qP&*JIO zRqiPN3``6TgB~-=V0ndY^iyXy_p$Q;Vahy#L?Y@$Mb9tbjk2DEN@0tTdN?GP6GUK- z4~LiICrZ!MDsNPbOCt>7U+eQ0p$`! z4+baGiEb%yG7l=bhPKpemxlwRZdw|C8qn$Dkn!}xPN7~t!F7j7d;ZsKi~8e#)4|Or z6|EL&ZN+{!)%qpn*9afStAlFqyJ$?pz(|v*D5%5#p^k)R*qI`xJd0BFxxA22JRY3C zrW=r)!i!2tAqMbr<|wp`M9+0Enr#rPFO92qxA4D~rAi9J#( z)RL1%v^+Bh4G6oYs7&iO(=dtX@)0~fVisZ6M6@VaEQE&#^_>U~M3tM?&l5C8R_B}s zy|q3f%!V;)w9oZ4R_8%=i9S79Qb479`k!eIvg~N>y(|5QlVF}wG84q54DJJm`$I&x=;+0CAk{a55 zV|nXO$50kqQt#;!xm)BYO@(^jW7CpIL2Czx<>0%_rzb!8=XV3OO?FMEu-}N+{I{zC zz{!In9(Bb0yjU$?7;39Wv~Qk7=L>}h|D#JpkB~%ko0rIxq8Oq%BpSv4sg4@BD0z^0 z#yYo*2#3lJ2}Q0X>+Qvhc1hWLF3~Ffpd&WiN1oa%VKx# z@!evb*GjU^thlbJMVl&`Snf69LOF!{w&Yw7D_^WmC@YUKGBd6d)1|CV_>Irjnu(rt z^yDx|U8*U$Z*HPPSJrrE15Mk=EDpKP{dv-sxmN2DyeTFvQq)FOw=s>ROsLs`*)7o& zNt5Qa4Sj2E+C^i6xI^Q^`=^(=;|=qVxyHsO`eZ}Xlv*K7y@=%QEk|W|zesgu9bd@L zjqJF${`LB>yWCbo`3(i;JFaWJV?T_Zy5`&a9N+G*NRxVH3FZ>>(*@RV_m6oRF#^B$ z*uS574;!@ND{X19n(7eYrD(~F9*|04`(!Y(@xEwjN=ml`_E5fCJnweF`zZ$wsV8RY z;PmfJ7`#||jq^?^*1G=IV6|RrT=$rtapey*x%I@ltF&~$W@%z9lT-M8 z*d6JZ>r1b{;zP_e_@i)aoO`-fS?CTu#~!s=t>VvTh$1#ne@ zZXwpz6L0=yZYA_uVC?T~qM;7oEe0zq`(Ib5hCk&ezo+Ois$`NmC83bvzP5DTF6ce6 zGz!~#===U=Yqzy2-D}2k4%)@mD!&QU=1~Lm@{6U7{j28niq2=OGarE~$$sB8lB3ma z@4pS>xbfzl{LdOmO76InUPbes9A@KocH|UYTG3?9(MbFAv#g+1iH7SWN@V@Fm!50A z9ZU~N_JLDTm-;a|buhe!{yjNm^*_>HZy0hqIQXr8NqLXa(e6!b(?so09!(t6*R|{8 zk(}?G=f^DP7Xiqw&yQXEeINbG@-9HAHk(*Ya?W8}*C$^UM&jt@VwUF->!H9k;CDuI5T9M*cK*PWCU%RjB4#p1uG` z-14EvCn;)8aW}%vW5l^U9hYAA+M>$fZbB|>xH5O~V;nrrPQQ+?2+h z8|N}AH9>x-=sOEq*Un;jQac=+Wd+_@F8}@e{{YfJEx&g^hghQVo!W6aPWs$arv^$S zPD1gCKHJGcJ`B@4%X&*$`2IhC-}wFDdw0ILutCN+fY`&I$zjX}8A;>mvxTByw`{h6 zD}p}AnSbtA^m#-_CVLGR`%Gt$B9h_iW^uq3W)!LkymJlb?YeVLZri>6`0{MPu#l#{ zzdp{sYF0!ZlrafA{7^uG5zbq(Rea{~kkBd5$3LT-cHYm&Buv7%D6C6mr7QZ|eRB1Q zorX<2y*J)d%1cQ|ejZea?g}1_$Wa2C;UdgZq2xqPM-I8u33lqhLI{a>e*ZWY8e-Yf zxXkrUVhlR(;WM$9mJ15}T=lmZutSi=h+@+328g_m-=Ckae&}Pv$3FeR)g9fmIbs9|9$4Y@?VH#{Mkzf!g8x(F;za|iK$>PH` zaVkm?dvA5VeYo7y&2uP26Hgb680RW^?=(!nqXZKKe?!Gib68%OYH9j#B)cgTRj3ip zsVPGQ^TH^BrorTpEZG6hT=qrUAR;7*F*4dy0Yc^j;l`-sR2@j@Jk8^gk8htXNx~*; ze4KAH!ex&v?#50G2Xc^?%V(Ov2$Q$X)SLs;gyD6Gni?`sdwaPPIJqx8?#>+GrB@g; zn1^@-`TO1%>ISu8gp0D;0IW_c$zIIMtSqHqmyyoQLnaz-ScE_xosQ`ega$ZoqL;F0VM}*ac z?MdW9D98HA9$FP#LjmT0q#po01c3z*MtL5lH&Hw~HwTnjW# zxr>sPEum4Dm$#RDb$hmF6xq6ny2z~5?`K5l%GxU95>sxX&h98u=CYK#5g7yZ@cLgq zkLTJz@zhI-U%I%m6d=u(cqUu|V1xTDrw`jpw{UQzJj0lAM-8}M``_D_gD+R(!S(Im z>)*rkuPhEYA-%h}VhMxy+X$9oB!bLa+dMN%JBAn2h@JPI`>y4i@6r33#-}0^Q`CjG zZeHTlB5aj`Z9C0pEmZ7cjjM_;ELjy5TQ@Y+7p5Il)4dflvK?7RB5pR`IKc?c&PCH( zhA68uswQJ;Jr#F36k$!^XKd1_p@sy_?pjc;w({{B+U0oxB)lWe9<6V7H9qRi{y~_d%9Qh*38q zE=rMdVlqa~^Ji!$fHJAnRz&WahgOEFs(X_$gCz=d@MP7jHn|3zxIx=Hs)?gBj9_({ zd32T>ktHLIWCND-3}LemE=-tFo2_9k zDr+{>@xoKC72LX+*GRL zXEiKOEGXSo8578@1zee`<<<+msWRgn+{Pnx#Gr=E;EKf4IP#S0R|9BOQOa4W9;l6j z1w7kjOvOe_ips=;ohaT`+Sa0GHt1`(%H42;BW9wu!yxV*tZ;6O>{*cV@^b4dcB(P&%-{j^a-m=jwgir1-a~4R^6(%yWnO9p+Q8js@pkQ=j8I?L3tiFX9 zNIW`h@r~n=^XtfxPosw-e|PtO_fGNBS?ZE-&zxqj^>ptZCY0%sCc`E(n`sH_jbp2R zH?G)_VCu*p!ZGp;@ba>F#~AX+!})!@AWFl&Xi-sA=Fn4Ve7NNEx?^6RHFdZU!nGQ( z-OLd#AcJT@{~9rP_~Mkj#(liEphvX#eKe4ywbQTl!=7~6Vb?pVaS8?x0S4%b`7(>7 z1rQEbO`zFf}s?n`Gh0$4+NjX_aDSo3~I=)Z8-z`F|hb{J)9*=3p`- z{G}>C!*Ku4^z_qim~kwsw`z&GXLnuFu-H&9VjP&Qc7ulzWz-m|wTo>VF*q>`-IoT| zBxP2rlpIkKvJI1Sb8Z$Lw1lih%qOT)YVA}FbxyJFW-6%2_w^u1lgjp&L-OXyp3gVeEb4$Sj9V1T#T-P-d+_{-zEsoC8seLZ4`^b!&Y1YBQDyC8`zz8YYe9%RxaXKIci-R7#i9AsrWUtP z*ms=SH}-72V@kxB6ykj_jIaYFmS%l0&}IR_9}5%mMf>xfyqBMu$DyyBdIW5buNnJq zXAD7h_9M`I38ny;%V1AC&Y!cu=mNl0S?OVum-qSJdP--2Tid7cT;b;_;kR~f&lZ{5 zS#)~Noc=$z&(Gw3DE(1${QgUc5&R8?%MDDkEt*3rObE?}L|BI8W~;eEi^Zy181#(> z>nub~#pLc8sWlYL>}#CnIhmc?5wj5Lr!I20lLDe(&9jpN%-b1@96gOEV=TI0$`tnP z7Sp4byBP&V?tJ_9PwVMGe@*>{g8nK;{H>D1)6jp8Z2nB=dvdnqpFfLWv&$I>0iB$k z>70P%6OF{U_v^O%^v>=*e9zCe%szDm%n!MwqprsXreaBdPF;hR4&H=Ep5@3TzfFxG zP8gG>Q?Q z!Kjf_$VM8J)V`E_MI9mma3p{g$hqfMWRi)TwJHm>7o79#<;t*v``uCI)cDk^l8zMg55CF5FZD{l4{YnMAZX-O5Q zPHUw*t7dN8T9r=t_axLEnWR>SSWd+0t=pM*TZ-#+*4$3)ovT-P6x?KGRZQG!sK|@4 z17*Y9R%}JRh)Oduf!H^5ZX@%-@S+#z(QxO3r4R=c6Uz}`)K|nP9xwpl^XYRAZ9lpQa!0^97FtSS&aKsYn zoAMmI3o(zcUz+Adox_d^hKuhNyFM(@J7<2m$kyvJtm!HH|1kZ2|GD;mKEuw7zJ{CD zOvC5$i3v6F*QX_cI4l{xGrwoROmD9`+;Puf&V2Bx>z}j9$G2hMuWz4>9vP1hOs0Pe z4-@+caJ`#+1~=)D0S@U)yBF4Ze}n7D_3Po5`pEkFh%Z@)c+Rz1y=oNkk@S8-Ul4?s z>9h7^()?~83jRufNjOB2Ws{7FJRKeqMnZJVyj^D(I>rV|)yn9@tzNbGu8aM{J-;W*Ba zd2hVH?JMW4$z29^%NX}Z_~vj+ZB1t`w+8C^(~RRDUrFP{J3%9=jB*=&g&>oVph*gI z7>vjOe{hAu-!{u*-&lLxp@H?|DM$nZ`>iSU?C#Df5yL1f^>;EsUZGlKd5B#lp>vj; zcz6tC7Qyb@a z8PA+G1vb^~k^)nc`bD1?5!EGa&yJTruj~82yZ1x?KhN*|Ao1Q~bZmd7@=mN&4Qh2YZvFGsmAD_sB`~?r} zp)grq*)4xkRNIg4{^v=;^QU%tPvD*$^V=U;K00$Yd|E?d7$ajmbh}A=w<-c}q=^yf^`>Rq? z##i`ia&c?8sYOeIA&ID&7kP1SXCn>m3^pjGQ=3KROif^=CM#wow68WBJCQ2mVdO-* zF{eaKgoO64!GF4T^ zxh84Sd|5JT9NN_zL2Ws8dzNAqR2OZ%PXxT0VkXvXG#;wRiJZKx3S`VfYjjQG0EM#iO(I5J_h~6$Dyu!8*cVl-;vvQIj%SJkEr&0 zaKL9H&a;Q>tmlkQuU9mlES^P>G)(>mztPS4Q^FeNkt9}6i}0IY82j7JO$WDspBnZv z;U5)Jk%UTrN89WBzV3PdKhGk3hGhakN%lBPe-@OUP2<;kv)(1!l>BQ<>0#-e*~8mH z&9DmLh9_zU#zg6qo8y7fMCqJVl9{{O!X`B3xXQ1j?GKhsSJmq!eC)mHR(L`=c-#U= zT+$VuLC1!R6hN3EL?z>{a51Rz%J}LS_WEWSY*)%PO?A`D+d5>@bF9IRbowOweLnZM zTPOzYlp90*ih;PGk{v_>VCF7IWk3^9Ad5g$xD3%4aHA(p>f<1SO_D`KLL|n^jG8`P zK#4+7S7<{hIEeN`BK=$mNrNYpcrHTV@>@PkHKs@elLRt|4B}o_k<)djp1RFX5i^EI z(Cdb$8SwEq^WvTkI;cz-Z!9H?06zaR<75dSSp#xF0~ux5Xl4cX z^YhQw+cEi!%*lcKX#KI4bC;=>(>uwT%W2%R!XZQv{-~MO%!!Dcct&WGsRM&DsNHTz zi#j0NxHkh_C{S$+;t7#ek__!_H7P+*PAf#zL%Si6r!y)RMq{x@IR+eX^6y5WBu(ft zqccWbU0aKZZ#KD!z=5R+Jl%nXDA5(bl?|IL=@bDVS#3d3p)yIZ_LL%`OsvWda4cZJ zwuRQn!0Kx8+J`9tZVESYZBUS$S%ynM1fJdGP$0szwbgS8NLwlpBTT@V7jFXyUEPa_ z8we*ifn-)0g|SQ}Fil!kQxhT0#U;hMQ9|Z!wN*KX4$#Ol^OA^@VRc-c-c!SmoQQLq zJ9+)WKqLZB^N0Du*ZSJOyA%3akM~dU``ugfpE;XJa$@~2?}|44IP%|yR)3Jy-{gvxxf+!Q5fczFj97?3z#v2i3=sm=6tdeDEvO}GO8j9)2#N-Y%Wsn3 z^ZzmLs_-uG!P^jAk_t9vFvD!${obd){6yMHN{J|9sK4u%AgcPAsL@doNMG;7GH8fO z3Md)?zyOo)ou2=q|KM-^{rz;j-}B?z|HS{LX7rthK_+m>i>Lp>m;QLTab&;tFPTs# zKln$iPaA8BKlY&+nOvCn?$%VvD{T9b9jMEVZKBN>@+pVZDs^XM<|>MThP!S! zvYQ9~a6lfFoT%{EtA%`s*o&_zVi9WPcfS9dgftxuGzv7cpR z`cB2Er03t$bh+=q3sZhAg!k~(l*_MlpsVh?uT9qip zWpO1FAW&Qe7?J=6Nn3Ce5f-6|D28P&C9KPtAfm;ASY5B;1O87ABP=YLfOOxBMohn0 zOs*L}aw0=SkWmu^e?hbVt@>)_(tbI={*X^8*YXq|Ruu&2-~I3^j8G4Oh<4;M84RDI zqMkrgbN!WSiu9Nf?8y+HNQ<7+fiz>9E|dX9h(sx&e1d-i@y51sXN`z<{k2BCcr+M4 zKHb!lOuzxB1U1L0gEJ6Jkmbh-%1Xh9wwX_i3~+vP{Ym<69GCQS-^DZPljQk0pj$h$ z!0VK&IDr82ea96tXsbfvvtr7Y7?Ngd@tni+J5QEo+1~%VLY)xi^5+!7+!DtRFi-P2 z>s-7zx<*cBbEfYT|%p8rL<+Y3#jhI*1(zdwVYq7pPd9D&c!}<4>Qx zxxM?B@w|XZ+9F!FH&lfgmnrrj$Tm=fF3bRKTv{pZw|0Moiz13B6aAU}*nfnZ13==e3gjN`Mx5VLI@*z2pC-pJl(vzk;koI^hQ&pu7-yN>(G3Iy-DSlsF2?cm74 z1T56Koog`ybNONU{&CC&d*Qn4!}Qm;crvtibRDY6%Y#>5oJZetCXf2`y;8SccL?bF zOh*xZ^xkVx*VYDpzNVk`kJ$U@Z@+8boa+boj$)&oiISwd8S~F3&CXs2{IX57w*iSo`O-`<&s(W76~X*&nF# zJN(bFKKb<~y`)>s&N5hw(>pK>JvUQhqp5#OnAbaOA*t02@9Fu_3+?`Tn0)TY4)p8aUB)}XtjCozCN=|!1Fi9SwD;%x#axTX7fM1jv||1qS7GD zF0Ud<`kwgxj_&SFzgIMHDe?Q6!)E*LAyVwE^VM4| znC-{t&l1Nbr%!xvXN>3QAJYdQPpjD`cz35yU!LY^(-ReM{affOCh*~|8AdEnoybZ_ z!p0Lu*k0CUiAjIrqRP+Vq2mi#<@RKoIggsozH3ouDs&7e*?Ql3Z*{n4ZBkcex5G)) zo+@Ts&ki2)!IL)+Qw<`XUzz4-hW@qAv2xnSJg5-ogg9}|Wn|+2Q&q->ssm>IzDt{b zZ=7bFnH>Df!f*NZHMU3h&|PG$pK#)Cy)3NwXz~4d#}HUgJ~jyci$?g{b)N`vrZc0b z`psQ;k;%1H>qnO~;4w-mebA$@|FeFsqRNHWsD7saiBHd6uSCyXxA(_!$1%j{aqFKK zLo7G9z4Hy|^6{G@CUwQ#{-%#jNZ6VDUf>6Us2)FK(O?pAju6-Vn?m@LAuc6rk&@F!tOXA)Mn3h6W*`H zGq0w^LiI}MeH88F=eiRMUAgI{eM~`Y`2Iyrtk=V+jBt7BhBu*K7l6TuNmVE!Xv#vE zijQ*#)!l0Q)!DP75+O8A-^!m0-<@Y4o%7dz`qzNV4nvUoYY@AW&c3~l&d%E}t9iG` zc3D2uVN(xRd_=~z^zt~WXNJ>1OI-cH%l+sc{cbdKnd{{8?JBPNZg?@%hf&8G&)jd< zey(PXM#K5k=f-|xrQNuwGR{ZcG;>A&J$^LRMSoe?CjBd8CO(-5V_iwrlVN>I6AgSp znpqo#ChSPdf@i%>w&oIw7kX^6jMTCB4cp?3e;e4&I& zg9rL|&q7M%FI1=NY8_2Eo=it35_dyS!%&#pH*U)Ho@;_mBbO7czjw@SgXm?+49@uH zo*(LYI`4;Hri`cN#DcQoIEakgzI{{j*kmO?)xOALb%|&qEVlcNb-ihi#Tm|c+)hDc zz5Or>Tg<(9I@YonXE`z7Ll2HS*Eh#l9gd(-Oa%|)IX}btnC(n+Bh)muaBdoU6AfRr z`l3VM#>D5@=aPaTxni#}KhIKmKZ~L!AWHpw=ZF=$o>~WoF4j5HL@+1nh;rGHPsZBU zRaA9(dLz>AvI&&bZAdjAnH{05$E)PW5qXWXb9-^dvx6+&KjHLchwme87s zyX|)!am(OTI`tQv`LpHC$gMyy-16Y(fi>1NKy<|KNS~wi-!OPReD{&*CGy{+lA5(} z+TV4a)gRsaf066gqz`l}>~b1)eP0R6gC$niv+v~DBIc1f*L+T~XPvhMZmWxz<)74b z8IQjD-e$03#~R3kM7ynC=gXfxE6G{#==|+dL)W+8IX859H#|fR&l9I&ZIzxMx6**! zCYGK9W+WSidG04SzWt2)%k7SQeC)-k$241>X==MCDe%UirVU-9LjRqZObnx7^G+di+bmjdJz_IH3amS4$Jq{qdcObj8 zcO7w$e_QL$`W)H5nxnYr^{LED-?IA~J2``@&som3lc7y6+9~xsOZmrxuAOx)J?X>~bPohsIb(e&)Kjg?7J9mBkD|uZ#7?4ZSid`{qBG9N5zg8&^TSzr`0o*P zd1E_wFS&mixASmgy|L$RD~93FUFdr+s~K<4*k6ARl?vm&SKUr(U%C30KTTSSVk_!% z!(G%F(^s!;qwM7BwD@@c6UIR`4CW6%a=1D6pIS-we;c^OP~fJqNI3#SMAqkRwB9H5Brhi-a&s9hc7m>I)E>gNInNviY3Q{;l@xPbvMmm8ny;sa}r^^$#W z`C975+8H>Bez}RFz`q{8r`Jq4&@45?sL8y?5Y_TJsm6IWTyKaGh%@L#r{fI5uhe@Z zr_hmq$3&6kDr2X|f{*(BjJ{%e$ldL;sLx+jj?X?Gk5C1B^)hk)@||&99D8y5Sj`;qk^5VVp*t8oy=ijk=r`aSh_rKfBlq}mSsZQfG{ z(@gmPAIrLI>s2a4LLO(6*EPQzl+gKS5tr8UJDoq#eNQ=qH5_Ok9A5oCmJv2U$cQi? zJWQGUJ#I8IXmOjmJA@z&to{ac}Nj<>YLHzn(0RpuTUL=R&_R$C09-}k! z#Rl?jnwhxaoNI#KAqj+03#8+DnVGFxSw!q@S=*=GYLe8zWO{e$tn;RNtDiHO-u5Tp z$oe7yC!{Jpk2Qe|A#2KCJmpz1;11|k?j8=C99vwl&ovGSsja#@ozD_#U#E9A*TVzix@P;;>q`0BJQBe$@c2PpVl*!Wp*wzQ^IxxcO=3$As^2Njiqpb zG4%reKYem<(Yl&wjfB|lq}PN`rv{Eln8&Uq=PUHDFkf`7XY$;b$sU5-C@^r*vy z`|w5i1M)oFv1aK^H_|WZ;~?ut#|z@D&g)%Nj;y@>a5J2|gP`ky7Sh{_-XaOZ{Zz*h z`80fPJGJqJ>N>_%s4@87hHHz0@$0XD8S&rF*L%l{WBMouX%zmN-wr>+4ZMR4AN&2d z8aR`I>(e08&L60bXBSlRN!w5Uk?{ZFaq0)dhdq&x{byjmuG+|x$r(GpR?++{yPMyd z1<$v~{<{Arbr~AzfSC~NeUS3dU&GG^^Ev#_&+2xrhtsz#!P$ofPCP%I=eRK02l_3; zc5D7Wm>9(O`=)?AIFIP{n90j~&n^Dr{#nQ{?||Ta64Lw^BmdZcMn|T`;GDU z|Ii^K8r%-Jduaca@1@Gqp6x$<7LJ&nsNsivWWVYIOxI_t@X6B_zXl?Xj%DP}d7Q91 z;vF5+YfD~ru^GQ@=pMH&MU5ze?980M9IW@3{!_rLNlVw~=Ju+`wbDlZO^cr<oqlcPR70)h^Obh<;m_wCeh~)*1x*@+FASUu-&_uTo+z3=bk1$8}~_7G$FxMti*KdTke} zavQVDn9Rhs-@Wmu>2EsR9I-x#aC&a!SGbQ=A}?ZnPJEA3=b*1GBe2Z{Es6P3Eyvw; z$qHyhk4O^}|J^kvU`Lw1O=kQ1cSC3*Ub&3ymk*rib=*LCqn>6!LH@>v;txmkP}vo0sfUr4 zihyf+9=guYt#!+KkXJAU#;b2rWAn^f-PJnmb}wp6jw?8Il5^j9sqikWR> zKRWlvqpbB8&oTq@$1_1#o?klWjKYXD%Hb<5lG0oMu#IgV^Ef^6m*+&ZJ062KG})Mb9$xQQzlLfT6WD#g(EW{9Et4dAyDyfWhkOjl5aqZT?o`GcCSA(czLy6i zX`&hzNsi{vFPgQPkD|wM|Ec5VYg?T9B3;CMp1WhB8eDZO{L1pp6VjL6H;L-sTbsI@ zQ`Hn~<~XQSxK)vfKdGh(*cAu#n47qv`lyAphu7+*%tS}rbKv@!_$*WGZAaADJh_OD zt_q)!D7F!3dhw3(+5Pt!rW-ZY&7NyzGX+OH49J5FZUlW4P&q9=TnHn!lI`P-igR-!)iz~s$EV|Q z%c66$+UtK;mE;BE3LH_$Q}FxyE9IJwd?VPJfF=S-2Q&yU{QjWJE8Z23O3#`W04o_+9?2Q;je@u7XC+xsffK)4!==Kq_C`|()((aG%noC}NxE(x-({sgn%*8`F53Sy z=_hU%S2Ep((e^dgb3Ga`<;N`&>Sly%K2Ukh9-bUaCMEo5IzFepH1W&SQ{3h`rv54) z(Vd#t4Daver%}#c`j+YbHy>5wL-0vE;q!fF37=ZXl3EU3*UC^y^U{wH_4{M5*WbUW z@~h)M*4L|xv?+^FabDJZTIfhUtEGzFbq61QObUsB`q>EF`JzhRt6AQac?gS5XhT$4 zooxgjd=VEr&FtmQxo2(|@s-_X8@`nBJZ#9l%Ht42o?D0_=C_g#;pANm%|pK(%tp7` zgOG1|O#EwB!-zd(#OE?08Sg!1y61eepvO(F?|k!9o!+LpVAZG`5jr66m*BAx7Re{s!Z!q>}QWm@BWt!7#u+wJJZo_nK)|M_UNi!dkX6jIormvX)ko%F!$(8Chc}T)PUA>7!#QY-ufwrLoIwxoxm5 zvgNj1w%o0S!p+gGcMYw9ZL~_VjT|j*7Pkv+A!W9)rLnYicQ;{lb~Ls$Wop<`*wU5> zY_+2+M%xW??&wcw$QE6*}7`B+ikTil(wynnG z)B+Fy0EMpUWx9-E2mk;8Htv>P)ldQf000H1?U!h6KmY&$0=r1tw%bqu0000k<+p2E zFn~ZH5DT`^vv%zm1ONaa8e4WH-P?C|R_S+Ls008Y02{V$-KOq|5mgaYR_+wtb-JP` z8>Sa@w6YD;OODn;ySnMP?XqT9P0=Z2w(At}>yBd_Ojn><#cCg*ryDZaI=F)7tO4PfyrqSCJ-O;uhY~3pj zChm(amrZL4Wu@CovA1n4*>_ZTZtc1)vuRC>uI#(3TDI-Fn%z@ow^Z0|+iR-pyKa?N zb==U@27?8#;4ol?dZO-u*5NtG4w$-`cM4|Un%$eOT)@Cw;AC5o11pePDG~qx00a>L z00000RuBLP7zr^|QAJix`^mpN?(DrhI70cmvv?G;a`tNTn;v(1zHv7?tyWmyAzpKn ztn69e9hZp&JnEtFaTta_Z&WB=bo$^4@vhHXCmMMVARYrp8x~F7&&hINYq8+G}>g&K>5;l4?W* z9#auDVcg=1sH@4kqgMh-X${U?*}QzNZZ|3B?ityk(;%`7AhL6Uc}{|Cs%#pVgH-}t z!CZ3<7YRvZ(tmcI5yhiY6MA@%9!rREQ&i35kk`y3R7NE+9PGreQ zu1?dDVk2(ivC9tTLm@3;1m;D;;ZY$*>^YFQ@G!`WSDF;;LC>Oz?zCAi8DxaNqf@Xod6KCw`xG-?(=|wI&~%8unWzWcR}gYZ+o+Q zUI}+}3iZ+P!H zL#XEG9k-IsiNm6F?+v;;*J|o?Y8pLs-rX-A9WLzRE(>rV(}G~$A)IR~BOJu2qT!ia z6)4SCOz`8C0g$U|n4y901SoTy9GH@yD)BbYU8%-BH(}G8U937<>YR@4*HRFGXv76d zgDJyphC(wKIE;dI{Z-X)Fb&;{=_=JUIG4)H5SkYn4t?_R0GkZViE z%f_4D_nXIcmsc-sz3VT(J9?gtjqT99fkI{hZ3r>9lQ2QdP7-0KdV}UoKoemb=bLvm zx8-c@RhNzC=JnH)pta>*x@!e_Rpr*@ycxRkw>4d~=Es_xV-7QU&N*4Sp)`iwF9$g$ z?=K!sYq)7-((Tz9!*2*voM$(Aj2@~6WlcEDaaCdvBU@}5tCxFG%Xc zZw;pBIP-CMT)D#7JIlH|XARBeN@L47OU^VHoV+Q=lqV~u*$9GBfu_)OYQsxG!jgv# ztIErY4>KNKUdW;?6oDCShb(0|ZPnEV%4HP-j2Ynt0zoafXrNx(s1bEx4c4%AU~aY` zrf%)svA9ERgf0Z#-IYzmn{2Mu#G7CcVH9}WK&etfktU+*CDq0kIZud+RgpDB#*C_~ z%xvrwAjP;!6iUccSTH0bxkekeTCrPIQkINMX>Dk&62-L(2!l%~G_*@?NZSn^EVn`v zR@+sUrD1DOTG|w#P_|nMYe+0XQX9K;n(8|Z0T3XL3Xn$yNF#zV5(^NQTU$4EU`>KX z5l}=3FmMHmq$0)%1~YedZ5`cqU0@>#2(f}=7ZhSJRsu!}!fx%@S$1r7fDu><;EpU| z1dI|Ou?2`U8D^&4tkBj95o3ykBH+gr1z-dipk3XY5#5IFkXV2Ki4h|N$Sw;JTz2i7 zrk7^jCWx4bh>-=vObEgvvt7Hb3%bW*up<{0fP%$hv0BvGy0qD~-7BTLU0@<0u~-O+ z6j38;-LBNTyF^HkFd#sI0v7FEcWm0sMG=ak#fXm zD5@+~6j-~IblqLe+!a+sRYvKTX3%Y*0SG_X z3wLZS+&4frn+m(6*y+1zwh@)vbu=9{wAwng(_N*rc3s-i&DpnUYHsaz6|;ADODkP= zU5!a~*{<6y-J7P{V5si8#o24F#NE?ZcI&R&xlp^UX03MayGFaN>1f$wb+FSx-NfBl zcXqDIZtUB-OSHAyrtLFsvE4K!yRz+XT?5ZH1QQmR8|qZWdce zSfs6`XjH8W8(0sUGdAg)oz01rQLuq#AsdchXw^|R)In0a5+$)9D46ovsDRr-kWsB6 zL0a6b1Z!n*QMSt9qiw8F2DGFUgIZf{7%T6bhsnCkTeU`}jr$4vffVjnYYdN zftsX6H4`FcRK0H&MM5GX^zt#wM1)orpjZ~zK(f}N7N$^?wg93M+?Ir`l|d1sV8xZC zV382wR9zYn>rGBVbp(PMW&$b%8{3w5(mL+nhY3fK5_-x&pp0v29BqklEecr{1BirN z6pMn8h^`QbN#!}JlC{CiQ#UlHh9yCRX*O@o+wCqQ`SC)Yao7=%NXR5)Mi>MjsemkD z1&koE!Yo4_%9)x6RAFV^2Iz{2b1RXx#SVy1LW36F`L{=9v5STx1}YIy#X}Vd5r!;r z5FjE*UwLkvo=fxAX>qnLAXIZ|9LXfsB9S`aLy=90K%lrnBw>id6Bx1}s0kwkNU0$t z38}Um*A~LU%ZCqF(Vla_oh=GcRBM24V@^joi zc+K-6%a+UwHD^tkn<6e-5E)xAA!H~i6)IJ0RV!61QAJT!D#cF;;Upze+=(W23zczg zq6}IQjONI1%X?n#!)s5R-cNd+gn@y9fq@_Z;UJTB6}y-TIgwklvW4s9ax&Ur-nSg8 zhs-)iwPnuInL{WTL6Hs#Y&c}Ji-y~6w%RSW+iAE^q9P$gLWqFdOSapsw$)Wd6;)LS zYUtfnRaI40RaRS3b-Jjks;Hu>s|}!T-BeLgR2V9&H7s_wMO0B$R8>Vn%Qd$}6;)Lf zRaKT*X4`ZC0000I(yqDy0000I$5GQj00002!PpzSW3k@GJTh?S>o zkfw-&5v|z`q?JV!QAHF{MGo&q`Mu-4-0k02-IhN$cR>!>*|TAuiSL$JppXLqFaib! z25&8QPcrt$&pe#6liuESM}}M(x7KHyhIy9?qQwnN>xKqKdIpQQkbx`A|+|A$zXul9x6v0jaF2ROrpB)*==WqExv%Q^y)5 zniC2dhTt>7$Z*&kW**s-N^ZF`b=`7hZIdf*Or@-uOI*rY<=eZOHI%ihNo!1{tu1oa zl9sf!%WHH^G?v!Et-&peS{B+C+Ci-?w5_RD?WXG~TT-^5s4QZzNX3j;#bWJuTLfbk zD;0`3tX2XeG~L=FB0*xYSgcWs#EM4l?!gpM5k(Y59o^GJR7FHmNo_WPB#=iB6jdB! zan4x5CZeQi6Q^CaW4QEW)OC-mM;M~0v0{k?fi+Q1j<0hL_?10gh0Rm000c$U0-JNhs)=8 zivz4fOH7(5OyiOa>4~bv$sFX}qjyD>RAL6rLJ|qQmpc(e9f*PqN=_)T!6!~!hh|_@ zM#aKBAl74z+hW8SictZ$h=!qnG}R*lXs|(wsbUB@WyPwd%A}5Knm25!PY$;Cz2wyA zN^??l+vhl4?`?Fmnx@p*yz`{*JDeNBInin7HaezQAvmUqisJpg?s*0+riYTiWcF@pK z1z0du6@oO zI4aO#%6zkw&4+m2z?<+a$UjC}`KR7tZoS+e9H z;F4j;8A&c#KtM7Q2FcMG@(e+}9s&amV#1SQVE087rGNM-_v5`{rUVb$I5{s-Ot zR{ZY&+&j<1%%M)7Q*T#QcU5O&W{C)6+X{zHdXOSLiKfDZH^t*N>F{rQ8nPmPf!PVJkr3Dw;C zJ;{n}`=bflc-*LorbR^-C#OOz6`Kcyu1ZKoNJ>&bAub7@x}Iav||& zYins~%L2t0Bas!r`LUAjqq|jY1{5~h40z0i8c7zz*JuI1(}aQ@KIN-B7Qpm0&n7c; zz8r+)ug5ctK%N!@k9xlxW*}laV0a}wRvKl7?}Z+3Kwhl8sZpgLN_trcC5Q4Ptuu6E zgJz&SI^EkmkZz|SETj&b5=_(1bKwE85cId7IskRh%TYyUrWpu|VH9u?f1T*&0giag zx+_r(?O5RAub1Fda4H)~l|c87g$(-hDl=aDT09>M)V~urPNov*2ILXUgiQgwEKo`( zdnUhOGRDdVDk!SP(#o#YCC0_Ui^cTpuK9l0|5QEAJ)H)+sqKuSUqo5yY?~bG)()(Xy3<+o^PEP6t`tztlqw$_GpRn*Hi|3BoN3G^Q)#*4 z+!Hop6^F%Zj`21e+ofnXJtlsnsI<1H37bw@39=|yXE;?@&RB#}%~~ZT2X31|CH<;h zEKRzteOrNeKO3G1CgY)XVb&%%+0tj-QlQ?|;hk(SO&RtyWVRHPs&+(wpQyV0O8`pv4gBaTpVOb11cvY$?LorD3IArGbNDlGuc{Dljt(hsdhhz#N4p2 zFYSJ}2RS}o%HI>A3cY{tdLTt;zfVDaXn$lip0GP1xn3VH_4=y%C$m1*Nbvpj`yulO z4?0sH)Y`qwL^}Cb)f)MlUI~4`zm#KO`~FaF4HDfw!)dHRvj?;KYAMKnLhu-19 zE|$K>46i!joY&VPBm24@SmmEjL^-3C4yXnZX^l#Cu0tHt91CHh%&o@G$n74RgKWO4 z8u1=0;oRy3b#G}?>yf!WRLKMHxdiuf`XfCEwk~onvb#2+ZΠL-cp1?$fzneu-|) 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
    s%4H+U533kddPhXw%3DF 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

    1vqa3m>m0Cvj20-& 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

    V((pOlpj+U_cHb zefm-9^z1AHs?EM!RoybRh#S~){bb-{lkLqTWuZxL(8DA9L}1Bc+Z?!ToJfGvv)08X zKVbh$WrgDVO*3^Ldz$5I)0D?&{U;mli4aXGMGkaN~h~l_XOA zV$xwa;pR|90BWcb#!HAMUZSy8zMP+B9kE?uWsN+@-!OiJ*u!+neU=~Ojbod@lp*uP z>=noz&eq{VqjCDR8qKAZhi(m7nq9y=y82Y^{<*`ktkKGMH|Q6u!_dtywr+iBy~m-i zExM6wVpYbqmyb~ADSdArW#2Yeh|2vFZPL;G(}>CSoM)e@xCSfQqYzyXY0T{dQ*cX2 zNbhN6mRYW9s(bq z8JSAX|B>5;!nK{9txaA`EOt0#>f|Z*4>AFwjtUi)n-er`G9khaz%OZo)I1D!WD62a z0I$O3Y51`yPq64c#a>l4F5|`5!scZt-%4>y(i&>iP{o1y0d*T2>iZ+Od@^p{)9{f` zgGgyT0LC#F$!rT0hZQZTeZoO(Hs=a2Cfi_hXnY3$mttpJ`4QOpaX0%*(nV8&6p5jnCz=pf zH|vL8%ogqg_bAu7{BfWWPyL{Dd!#{yh2}t~b4hD%j-w7>Mppf%-eJQ4{xY`UDo1&d zu2Je;i2alSJry-py%JXn_B%94rq2}OHE~aE+4kdya$2i+*CVz^o{oXY zTb^_}*L9C6f8k5Txm>G^&GXpWo0SU^?(zOSA~&whPG(ieZMWk(JDyt3U62>j{!=aH zN^z#{?tm_paBa70Gb{I;q}wscfV|!4S7vaUNY6Bc6xt2f$Yix+Tn*&CR2CZFn$yH~ z|KU>Ad{u}NjMxP>kT`Tily%xlf3L1M;3l#n$?m1ViTl-$+7XR!H`jqyi#d2`$Rxm? zwO5FsXsd%yeGG)zCN|dgK~55$U*txk-PmBT>Dy=V%jb}rs3+*P0`qhI-MZR+qRz=~ zJo7&z+6T^y3SCg9L*taXoD&VZK~@o?wd=iS){F1ZR!OHOE(C6zUc^(zwYj%Lhnpsi2UJ$++_yO{J6+!2RIFk-jzmX&p18HO z8>j5ao!)4cwbvtY5-QHH`}5GEyz&c)%fgAP{k4Ei$p|9lZl)gsm%Al1N{TZ z3}hE|mt(TYqWwqGhHcXY^|v-;c)hRyL56#Ta~Otvu=x}6%?HG$tv^fAV2MSr($$`p zVW|)$M)^>W0%_0ixeA8*&(OR#V!Q{h8&CCm4I>-2^!=6;t?#$mGEi_Q-dtAwnEPfU zyAcOaJ{O8#hflcX0$kJv&byBipu)|*9AO^J^L0fw%-{8c3>CLDj|@=PE2mRU!IeQi z6Ce`#v9D{|pff)`FCgatsssa-t)!R7vb|?a#@iS_6^>R<6>EMd%4^L-fs@~#rnrFv zp3nGGbNg;n%QWxcOBQXa9$mq1hY?B7JC=6hb`bDcVMKw>g0xYo&V^`Hw^ zef~aoCzb02conheXL&Qkbb9rfFs)R38&0`QxwI;u=RVEWFbRsrbxmJuTDiNuT&@?n z?+$h{d9v>VK8cwT>kS+vU8}x_4PdbDUOv@&;6ECdil5#*s=;}g)OiY=NzCw!OC za3NWJoUYMU6B@>qCl(J<8#DR+H6Q;2K@t4Glj@M8peWX+`t#1EFlts|t+V+$(mv`j zm0CO@pdX4Y!Dd9G{>K}Ct37ikn{7p299uiuD!HQG+y$467(vAPch^(UglD^~u5zGm zAi7KfvrDg$qKo5keL4+rHSYDQ8>?q!7%L&s`aV51OS{Hm!B5v}_PhXRAJL3Y)yUrg zIxDlp#+#j9rEy1gxXet9fPOH`)Pvv#6-gZHFFQ|e_U%3&k-W+}V}bpsv7fKub7zwz zDGCZ(o39klpO~2u5vYasaHHm0&Ad~Zjk9c{_L(WJnt%xpS(qz^xA)iNqu5x}5S% zJ)i3R42eRo^5Kw~PRJ6>qS%X+Q8}h3mp4nJk}rz9 z(Z>qYSp76dq7XuhEX`{eTD!2cW+9h)I!h_Kw5<=BQ(M(^N{a(F_3cU+1n4m{2_Ov&bgoJR%`fO!?=nNYnpn zQk;xWs`|gb5#X=#YROw~R1lo()$sRL$xQA?fvhNgXDeT??kxW3!JzRv0i`pmTPt8B z^P_>@Zv0kmUX}!NrWJq_t97!#EEAhnny7lGsx2?ioJ;|$$wo3$GJE&n1Bu8++LW{O zL@k)s3Ok0vX&D(=$NksYg8UJF-=l2gCv3ud%9E!)sE#$Xx8=!zYZ&uR?&96E$L|L6 zQ>yK%M035C2F&Y*&>ve&)M#eWu!g z`y6o>()Yyv9P;cOvicS;?s?Ka9iLM}`l+w`nf+>w-|l$=Z=Xhsx%{?&^6Jc$NHk3a zCpU!Y!zL#pL*|+lKLBtp2t~^+GU`%eQ%XcQIp7z> zKxE|9w5!UZ&eN#P`(Ya_+Yy?+&VBhSRV*1<$z2&*kjElfOXwEx<6cTq(Zf2qsZVh{14 z1*v&$3+LUZWS+`U7Nfz*!KG*BxNuU5rl5#|&Y5s$rmZ)vKI|cC&X)R`pCxmN=yOpQ zwSs>IKvu*FXrbuO9Sw`7zYZGRXp%NRT^!5%<`l$vKyTNBJUwxvqCVhg&6oqpSTxVS zKHE|++_C*c-Zmai>Zn1do$!%t^*U{Uzy+ z?=&_xTX$(#cce1cB5!yi`_Xw|Z9Y=4_7YIYKAFLp<5${+86JZht@YhxBlHvD&G?&; zisf4G+d}wr;n{;|p!=~kMXSGB36kF3<%DE&$5YUz^s9--sclZPNYiI==zZWn2R(s4 zFj4BSu>I}KN;tm$GOCEFkYenromYL7UNBW>S8{tzKwDI>fHGG0@QQ#qsl+f}-P$?X zt6e~Q<%9IWRPTz=8~qMq%Z+?uz>Z`>i00s&Shn>;!kATCW$4el{?>XUxj{-qI4r2B z-+BGLGzC+@f$h8f-9GXA&d?*s2H`28{GI8v52moL+=oNM-Yo9Nvc`5XX<4(~b(`3% zwER}Mf`P4NK*%5$rdP$}-aF?Z@$Q42rbC6O!b9e(^6gG{Y8~I?{Wp*8KEE;rf(*$b z`-Vg45h23^V|qdFz&1wLn&zgwo4G_1q-$;m&bbdPv1Qc_luOsy4)B7fU>4fNeG4~i z1Dos~qID3tSG(V2OsmpnT8vD)T`?P2L{Jt}C&mQ+&vOf4QKji2#P5>Dhv?yQDIF~_ z&b=C|q3;IsfiAc&j?AI#K*aY(@4^2>M0Zc6-Xu>>EDU4*gV&PY*-G??+wXy@f?40k zYuL&AK>yXNSiY`cv`6=}BZz9-kSwn1vpKBhl5Zio5g91c8U< z5T*Jj%A{$MExk>i?PF40(K7ou_^k~IxOjf8r>2{F%8{QjED24y#QCg2YhYj+@lAAD zF!c(>2n_xh{(JQK@$Shxsh$}=ZPbW@E{ShSX=|Dem#~t07@>r@_09Ekg^;ZB^*K&& z_&s@r$?9mGXM`R|&U{EaYwXvfT>T2^ZJe9Xze->NLEqF?b_T@*I|vR1wDV;inHKF- z3SdWU7FFHKybnXas(xjSxL?`aO>rBMQSf|R!pC?lGTok=Yx=o;Y9&(w; zEW7S$rwgdgHc!pj1k^Kg^V*f(R(w3G&z```w8B1syxC;3bN>JxB=L-pvv)x6quMSY z;wrXo?LIopiikAvE9Ax}hiiu2sP0hsoz;;`FOO6TBoS>crhN4s2+q@l#k$Qg6gUVa zaL-5W087nK?(XjKPkfQszzHksF$Qrz_{?X(?XyUNYJxv0Q4_|sbD_47gvGw=1)(1p@T0_n~$`Fp4wHl-@^-xQ~S1JDUG1o5>xyC~! z*%P*;DtzESj&0~)oT!`huz<%-RJGoE#Nw81TSYfBV5~*1Kfw>02J9cF0=(LSWoV}{ z66>1X1D*8WBX`lMg%^L8Ac=~Bnc7iNdmvKPR5>0$N4wSuScq-|O z^$Xl(?AwM}7)0jn_Vm0_;W@;OLCn}~_ufWT+^vwdnY8i+_xOi%H-gaoFpq=XG)jBz zO$SqD0wy&Kjcn7UlbAd6KEvN1r;`Q_`(t~lt+OVtTT+b1Gs0o*4l>8_uvIr9^#l+1 zTa!kZ;KRkt9WG257-30XURGumD~Z?LU!^V|ntX-??kPaw-9% zaS7f+#r9%{w&hoMU`Y3KkAonfVdmqnr%Q6JZd1m!op*&iHN2NppA!<^*9XB3ihso< zwr>?$tX`)~4C=Bg7C`lbbu+nzfy|{di2_a=##@4=#p69Tn7J}@oghAgUN(YdV~Up8 zzByc1$~2p88`hLx^QNirDfq4F<@CJ`lzVil1h{5gJHC1XGpPc4N>soq<_0S;bh z=4Xc>S_bn#HwEM|=1UjRQjSWu<$(bCwVH}Z`fiZXG@!|2EHbpyCA`g`#$Jrt>1yUV zW2t!WMxx*76_$W(dZ|^2OQGkFPf^ji+~~_5?;h?PRMQF#E#r)uy}x!kIxt(5Iwz@Z z`9^m-%e6dgl+-7MZM^UG+R_9CTDjSu9M}2!SM~39B?dY0j4InOpNOe*y-6fhN`0_J z7RP4&7K%Q&MJi8qz8<7i2(v+eh-E ziCE=GW;bWGT-rJy%R8`7SI0xLNDL-YGCKt=9d`X5aP!-`F;?`;)fukI7rP91{k#3l zN+i!ouJiUBnI%;dKA@W6>#FVMtqP_~v@S@%7QRO@F^QGIVlG2&lB!)|vFnybXr+B_f(u{`Eypgq0Q)0x) ze&EF}2*J#pFl)o^cje7C>UD14{{-s?98$2}Nq@YLE7eW6)#&nve4|s;KJ;i3wPh^J zZhqoY+DK+zPTUr!e-T9qgYA;$^}cW2!JCG+*v7k5`OGm0FO&J34Q0h$fQZtHnPnxElR3iHpMPjdGoHn1(;fJ34D<;NSEAzgiTIO=&ZKSx0sP8Fe8 zTw^WW1$1|1;k~*M3$<2v9o)3Qz`AePsn?Rpy~N4BASg(DXJvr!Dr7StWUIuKB23CZ z`2gSdc>Fw;>I|D9z!aB~gK!|7th&x4au)1e1kY;cRrC#2i7w*$ckq z!#o@)(!c-k>tUoho#Ljz_-4+>ZQOoA#g6@v#pzWe>-@+Mdtr&;LKpdb$~M=vcPEWI zQQr0dJm5#DkT$vWK9Jbt)>CCM;8x6sutn9zFGAY@^>LtIE}zfQDY%J+7ud0R17R=b z>%V0HF}~=&TGVbrwRM@@ih%Ubcrw)G@9Nbl!ZVDQP6gCRg0EMhO8uadh%YeBv|f|? z2s&c}rPU~SO>F2y-q=%Z(Q$=BfT6Z!ozN(V7=(O;I);{dT=n#P_6!M;di0GK+n8{2 zn=t`C1>~JCzF!2CRE|^7u~9f&KkH;EZuJHmy1*sc5CN`mj}RN7x)%P<&eFsN;Oelw z*#qY9^*YQyG^9#5%S+}5+xyfsYHPh&lY;1~lQMLbXZ0U(tVs!JRZhx$v(McXRwyM` z_wm#u&>iznN= zjVdO1xT`wf`u-Vb>s0{cB8at9P;QItJ;}me6z>3|mQo6mCd~qYoM>`#v|LL)^t7;`BbQLD3jtG8$vrB$G zV7A6r<^v)tUQ%7rFprBrB<9iQ)lZL^ETHvf>Yp#YkKA1(cAa=Bp`gy7IoL1i)=|_9 zlF;G*N6WSL3joS?R*5HIDSnaAmLb%tnz8>g63J`cCT_Pv=yC%|tw1jUNr-UnBI{S= zqRH0BWP7Z~%Wo>_wJ)q0L`CsJ`+2wZ<$1ta3r>eC3smv`q7&N`h@TG^3EBNrErJlNYP@CPp*AjMYd6s}tZB)IIZ|Q! z7*xdOEC=$6xgI7d1YTw;e2N`VNZfMgU#RC=TaxOx@9bRyVlgRNWSmlxX`X7+474Tz zcphee>R*8c54u0Kvh-(FrnxZU4MmGY)qK_gseGlTC7Fb6*X`F=z}Z=&+{oh{Igbsg z!zZy>IE*>>cjUTkc!A@=`fO778jUBvM{x4ENPq}?oF%Po6Xnva)T{8<@AaCpD0}Wp zxi_c-2;+LBEK}S^GDuNRkLNHTV7_&}gBR&KdYcPd+nzQAR1=m9AMU!V33PYLLA}e) zh2M!n@ZS~IsmYd|68--AK$_aT{Qb9PK)iIt)C%L#=oImz=RoO!kSa?N>2LdY?9t%5 zancABu0T`ckM~kR1+7BL>;H4I;tvt)e-HY40%_zTl-g;O%Vhdl&-^X5jA@Af%>-Xc zkQ{qW{P4$q79nnSQ*zPr-<|CXclJe#V#>31Rl)fM({6)8Q0<3L=?b%89fA&1S7X+l zZo6LhYEerH8Td#tU8&28L77NlqWK=VK!Ox9H9>io6JJVAvY$0j!9HkRh6mJ4SmvrT z2R!PD?3x=ijW$BwbJa4qSV|gVmlsskOlD^`a|rjEX3`ZI{gkzg`s?A?>DmaCapvJT z{wAWyGH_SB$Na5gIf`(r3nD0sm(JI_4@D*rd{c}SIWq0=1&QC?@N&|c%d$5WHk5?> zZRdQQ-qt_F@`r?9Hj3&eE}`NEGoP7EPr6=sHf+dUq%^wn$m|hBzce2TP9XfT!D&7fXq2kbqF>V}GV~tH)6^L*w^EHq6vbc7a zbw}#Ld6>K$4i}=3vy}X!4k0Jnn(($me6FVC4!92&YuYYsNe9~xJ2c%NvZJpcR+wae zKw0MP;mS2l=A|UP-`sUyzP0L=p-xO}I_>%!#7N+47@S+sb4e|ziXGW(Tqr+5TMC1T z#x9vozo?%WzcB{|7fc=B28-*6?RVYP_tbZu5ie5Tiri6|c{Lr!Si6D~h_YhZPOFEK z57v_Mjct;&HMGLzJB@}r&TBArI+Nbdb_PXl{-Jq7xc=G_V(jm&U*KrQT~iMTlAlGo*g2dM5hWFb2x z>CX^+w{etxXsN|J$F}AAV)4(^auv&S&j*V|r{mMxzXK`V;@LN)zFd!L<1QECY;h5C z-ulRf20rZzsq!tFy?=$by?^Kfk2--}_E2J;{Fo+=*V4!ABcn*0Iy(NYXHUq$8A>Lr z6XPEVQRBSgOGPs~+pVg6Uq_X6Xw>Zj zJ%>GFI7Xe^o7R87ke?!63Ld+;_o+C1=kd=DRWoEnlQ&~Ne9|YVY_yNTf|Y%mCBMc) zKu4nOtI*3iFRH!)d)bQ#h50zP&m>+W;`8iJm(SBGlt<6r+OFCJXkYpy@wkFYMeV;8 znA5K6%yMLMUtosVZuXBi;(ND9TzO~b@6&;1wFbp zU3Y52aR`4Ne`!EfoG%^wC9Q@1Oi|%eG(NJ5mN2|Jl6W5+?2^xF7qD1B%i=5AaBn-i zDG|{#+4<(aiOZ}j$DQ}Lp_4QF;8xK9zQyqT>JG;K$+f!`XVPF{ZGJugmD7>;7(|%_@L&J^_kP1!z2rNev z)YV`jcs)`&u5B{UHh3NVylhHX%j8Lz|5(&9vq{mRr*gsg=HMadqFCL)%99uHT>I<& z;!!}%2dYy`zN}||Nf(d(LGS$G1wx(qrmUJS({0s}^M{+zK5oZ5sp*%V`K-~P%madw zkUe77V5rF1(LdZfJQ!M;VBxc@tB!qr&ktVY^9kz)!s8Z(J`(*X5hMIR+y%0;OANAf zzc^LI*`auESwKcc0UXnXTG^M?mRJu1VuJH}W!Ntx?LQeYmP@i6)?&symj|uuZgSh* zA~vKnNz(F5E~OyHw&-!l62ZKd5^-S7a{IVhs?Qn7E=;{xvR$AKDoOqj8&&Z~x2tMFep8lLg(8qDp+oc)FV+xw5cF{MN zv?36|64ieFH6Bzs!S-m1yhguP{GMvD-p`4a8l4w(6d~oRa4fNm#+8En%mOm4OsG5x z^xy^Ua%Soqqq!AOTe<)`WPDl}OO@pJyg#V9Fom1rc14{BLX#%8O$d&Is-)o)yB!7e8NT+>UR|kkn+E}7B>II%b}p)fj=M6}aWg*ixE1b6 zsG}TXB3J#R+9LY~$S$_TQvxXY_j$_5`y$}U(q)k@Dff#|r5wyR5!Z|R^jb6=laag9 zlRCu}Rww=S{pQ2*Tv{Jc2CdSF+rshZYNNwQag`j2jgo^|Smrsbs#??ibgpwS(=USf zXc)Y_=H?|!;868Z?`lW?n;5GNSW1xLcqeEAxd>%(KDN_hdZl?QkmF8+8t?$#6Ub5N z&LaP{mbOtSn1w4#sE5Hg@LBskWRD?i?u$zlUEHU_@Mv2qPBy*tf|X4Nnn*_ffPpM2JRg7%`q&@M$rSOzV2TkC&Ghqif`o8x0#p0&h5kya?-) z>HO6jq~qdwz=S-g!9K-SR;fGR!UP|HGo#e`f3^QbbJ3(`?|bO+vXb84Z+HIMM3vz5 z05jvW?WY#$sqXa>!_cAbL7X6a6Q)9Gu7SJ2W3MWxc^D5goa6e|<2Kkcolf>m z^X0-gN-0K$(Yr6d<3DE{os`Fq8p@w5Ex0FtJ84W>pBL%mMp1xf8I2z<{Nj0&kGIKO zXLXP(XVf3Q*v!OqQDfP2O8e^PiHMZqZFXCP&eTVv&dBm?UwKo4LIy$i(SwL)LmkUy z|1DF)IzhO<|4f-)%^arEPbFN&ZKNuAgF&oFsdWh3>tFqqzEczVxmT@ zQJ^j^21TRFVBKnkWbBT^iY#@g6l#2QYAW4Hy?tBO@J6nS_3)ML2G|K^ zH!~=Kovm%gSiThzwzgm{*`MQ#5^r+S?ccv~vb|i~vz*{7w<4nVV?D=+*Lk8mH=>JM zg-tloiLG7Yt*q@R9-{!2qGVc_^qgyBDYo>MnLPDtNouNS`n+gt`kYuCvcXSuiK6cxlB&=4grq*t-MDL{jKNPOP4K2 z)|W2AfFp|~uYKphZLn`p!qG6Lh5wbvv6y$@O5pa!C`=A&T3IBRJ7BF)JhlO_4KK(p zrSdhW^boN2?EFabx6zvu9L`wsQf)2g&DW@{f`C^o2nM&BNCkULd|CvDr+a=~+%y>+ zJDv_TXE!wt|Cq0_TjI1KYKk`;X5HV|(3;DRF^?oN%Vhj>jU=+@_uX&_Sh*;PBr>N0 zMMs-4+7TIma?X?djJs{d$|6ZXk;LYij8?IFWyM*80q9%CexjMgX6e=$<@V)zWks3P z{JBCl>xFc?v3k+P>Csi?Id&ks^87uKfE5}si@6DGY$d#gKPNsbb}E09f7Gg6WLb7G zaim$cHF3Cd!N$fFFpvTpU*pe-jV+P_^DXi^(3X~#0RS`*$0~o0sEZ3dV(oH=w+2zX zw2X#cbaZa>>2+Ic$w;{LpF>7N<~nn2>MFob!%rJe8s8_ff7Jx}0`w=KL~+4odA=AX z1)hcslFY7_*f`{}?l*`D0W4V0AKwyf`%YH>2#j~@F22W1VcVW=vJ134_aQawUasvq zB4GAk4%iS-Bk}=u1(1m?HtkEod^fMi>+QNWMldt4%OUwNGJmJ(?pyM-NW-;Tcd?PT zZIOWrJ=@`zpo;H*KFYf>HUbio^t&Czu3hZZi22)WiOJ9jqBV(l-X?rlu^qu9c`aK$ zl@HTRqzq8YPdL_1BtQ0FX)-wi7x#gg6?0Qm<>o^^`xXlR+ZT({Prh%{{ zq}k-X!4*UkYe(_*inwLX>hyEcZo63oR!ZebvQG?xyGAKr;i-&ANL zW+Ng^m9MUOm)wV)y%g!=~RVB?akJI6>!p| zmC}~Bb9>#8x!QYWBy{2sbai~&Sz8A+A68s74eA=bxTk|sG{{;&Wp(#<;RQG6@o)X_ zDT3=c`UH;;+*}RoL{uo8QQIN>)xfh(i~Uhrg}3jONK$_$ODd5nMi+lC#g%b@HEhFj z765dR2K4Ij{#ZeVlI2j@UMH%3&my& zjh5Jhx&u;rE1KZ>`xS&sV%HE#{p3sb?khKw;Oi;ryG5vU?}chq04ym2_vi%m(eGp) zFGCvzTU{mDfJlqV_E>%0`C4Oc1VwArua7hDQLiX>iYvfIcOM$GBL}*kpxCI?l7ZPsOQ#aT~-F zwQa97ILSIh>f4+zj(XYpXy2BIO8-&3Q@?9auZbKS~dVlySPITTYBSHkkKHJ1xq z&g~5vTHTlO6Yajz?=WCFoa%xs)K@E=2(OC#ik&hJz=av6TZV(#R64rijkFC}N+5ge zQ3c*)w`;femU3{o?QOwWTy0IR^Js7UfoMF;(C-*Nm0MflzdCKHoGh$V7_fm!jmKI! zd9-m&ESvRq_cHG#=YjjdWph2QSmyjmgt2G(#)R-}3LY205A^d?m!v^)$Vo{M$Z=yM z<`-pJC}wsQ?D~|~bKO}q41uRz}uJvyk?nqV{hN32$PO4 zlIIC_KP2T|6lM*(@2$GX_4w}S)a+vxU$^3Ev>y9i_Y}x@*)(-+G=ieRw{}4$Wzv_; zz^sj7eCa~wLb+zpUHpmvRR#qm?G6O)pOrx((tF**Bo=P_V?yJ`!-m2yaMk-~=EXAU zzL?#2-$LQ@tM0W}s=UorA{m7#xt6@*a*q`9hVTIOVx5J7RBaU}%c$@fnVLl32)-3* zhlG$)wS}KMa4@qaa<}8 zDs>G$x2On;A1ms*HK%K-#;wP3T$gQ@Mx(j1N3APJL?2bqG}69aw*XyGW;NQw-fDaR z@JkBfSXNOxMeuCLk6^_NYlXchc9vX=5ui>f-HhSKPNgH0l}~pgrq&p=#1@!yCnWhW zDV=VO@{emaS{OsI>j<_cA}Cq~mBKe8PC^uC+B|xBi|9czs}HLw&EG@cAju*RVrS!F zj~cr2Qz7Uk2*kS){fw1OOOYZ?lr4=FB+`_Q3zJvpV1Oi#&ZQFV*o|7GRBpveuSF`5 zx)amd%a^3Ykf;-EJHK9Y8rDv3%)=0XgjSFwx6!1O4`^~3hPI<7zki`gL&YY$?JP)b z#Yq#(h^AgS?#@r_EW6D(nrGEP8ej2F^+hSlH%sinJTB?9Ncj{^Ib2!EpKkAIQ|Px+ zWEWByR-Hy@t(xdJ7;RH58E&N&uxw3mpUk zp@T??f{JYSIsdnUd+)Q|=ic}3doREB%UWx$S-$a&G3Oj>Wo1mKm2vbqdNkas;eLEL z{!`d_dyf;4IM9}ZbA#u>nR{mou0V;Vn4;dG)Rzqps0EZART zI9gnmIkDWvUv55HRfspWmLf7!Z6(WwNtY$b&{LN$L#HBBZO3mEgplV>83jsnCk~s( z4sK=)(wOSJ@Kf0GRNGE$zv?en#H6s|tF!IV$1j<8HRa8!gb-EAu(LR+xs(QH`iQZS z$6FR7$_hVSmQ(>UYBh5vwjHsKReyz@M0wIomR0I&SIsou(i%X>B6avAR92}vfYlc^ z68gyEw6d8vIE@v+uBv2ytYnM{Nl}zr!dH0_lxDQrOvb8UT~&}2GZ}*}wXw>Mkd95{ zk8FKh#`2`bDyvXM`Yg4(P}}Py`MJ&LuR@^K1AX@vwi1tM#}a#vVJC;irAd>m)N0K+ zU=A@cU9umQp0ahzIXZF!j~T697g_AzYBjAI(8ywm@^&Ctw1lzs&kSgmCV|gx+w?~X zDb7u&xk*j%*O*TT)tX6ERcA24?XqnmcV=J~6Z{45e$n1jrZ;7ll@kfK!Msh;LTNdx zHYEu%sO!`eim5eL&ZFvT4D|}z_-b}mH0;$nAz`C;;-#WGJ#tGY#wE53v{F`a)$!qy z1j2dG_t%d9y4B$FYe6iltV^V z(gmxZJN%a~VmL>y=!^tE)N(vKpKpCF~}TKCr#a4sWwdwc;npl zJaeh4<8J{a(sut^{ns;_SK5pJXPnruL?}E*opUX6?;6Tj`XJgcPnh1$i%2Aj4!D?#nn zIC$QpsUCv2k2m}kdU$1cF}S4_&iV@7uywav@k3G-OfM-(IH`#c8oXe^8U$?iCg%9N zgmCvY;@!>V$Kkx^SnESlQc{&(6{-ptm&UADfJL+{9)^c6z=eUKpaEWohXD6S7D*vR zw%!is*%rWEj|*kE_G~7!=!d%=ww+CUTShL(%+NQm`Z~@RZ5dI8Pg24oC}i#6TQJUPe=Hj z%6Ae4`TYcG{c2czJNSHS_o-Ske=X%`^a8-hJv}@ zFmX##bMogKL_jxuJ7}S58d1_L8Tmz$Ioy6VMTs>pQhDHaR!m#a%L&7$a_5zcbLhzi zBXlv^%-Z(8N$X)a9;^`f1-xYLjeE;Clm9?0W=v45#)*G{e@-wZ-GpXgAuuSAiIRzl zlKpA8nPz##Qea4{gicu&3BO?;-FL%1mU5O_b5HYH^L#6m1`y6g81gZG3vs2!l-bc1 zTlVxwZroN?MjZ}B(VreHmo=eHuVk7K=4*9ta8FK5qv2F|e$U~;?cu#gVaV^F6odVJ z@Sh617(In8gllZwQF7>HI2&(+wD0SCVJ$D;?hqdT*DqP(<7l2LYLHf3u4^q@88Txh zEd0F5^QFT-*IzHJCw~8DU&3BmZ>ubxG83_u(CSNBY)>Jc`c&%VV?YM@@Ct3foU6HeinAm%(pp?Y+d!&4K;WKqc|CBDK`fu&Rf$oSxE!MwkW?6thPotOVH$1$qsg>1Bnm)V-Z3RM#bG8~ zgjp*JLVpZ84*T@loG3==zy9I%y#CQW{BK{-a=koA@n=(dKyqEDwF{e&j@-?fOsT~q zDIqOCwZ7a3N)L;9)tnZ@_pWop*XG&-m~Yy)O$3O<2NP?mXU6#Lxng>DwQ(F5S%Zf* zzkMdULoff4SM=J;Cmc`c50?JIUfkC3ZH$=x9RKHaqorhgUR)5zVhy{ zd%Atv@Agjrd0g;ay*7V)0$-sJchom|XF|JJDea3m%zTXMP^DEI!~jv}V~7q0CoLJa zW=TM0@0`eT9(D%Z{Jct#7Ri~AsEFzhIy$J zR>d|^ORis@ZlpH2G{!lxcHmq(yQ~HAY~7S0kz@r3b}68fdZ&S%ILl+gFC1bJkJi?7 zyd>E=T%~CT8let&9P+W7#PoyXnPCJhsNk6wE>Z8xJedicjEpn_%7 zsjT5-h3E(OS71vEV`Ie7#u99cIh?)+vw`UzYEiA?e1Z@iq(O4pyqpso(u9w3f#u6p z%|aW*SVtqDSUc0Kj5-uV!V=A7qq1yXTNP#HtvvU^-^=#X(LbL2BR3*^@wcz`i_JRl zJCw3=&^Vhy2&1W3h9|<0}3H7&iL1l2C+X{0FT;! zY#Nsbm!0$%LhAi~#ndN0z2AMNnqP!V8pe@8z`2Dpsu(M>Hd47=ZTd3LR@eurfBi#g zcO5x*(Tn3Sb6RpGJMmEW(OivXnNH(Tr)phsjX5C~gVI&IYNU#p8#C*+ayZ$gi-;;2 zjU`gcXh@hCw2G^V2}a5xSok3pAX=7aj~)RR+Za1b-4t*Rr$nD_t}SKe$(*zvu1Res zJ5~n5G{?SKd~!156L4ou-$Wcry&2m1EY7&Vy6d`miqhk*(piY5$aPIc){cAFt}IhU(kuH|{_+i>5LrCnt9P{XRmWY72zyp}w}#ItaY9Ku1qX3I1q zh!*E{z6WP&Ip7XIBTU_2cyZ<+H65uVK>N&NTCyo`5vEcL)FB zL$Zd#ahV`R23eG?Mme<$W849Au&o@@kaCQ+JX!-;=bMh0Gu&;=MkI919v{y*f>Z7bw2ixy}-$n;F|XDz5YwY`8v!S36c-dT;uhSQ+Gx+vFf(^Z;-@HXA*j+bUf}{b3M347ByfY zNI5zM~rO}-2JQU3*Xpt@Y1(R%|` zj|TxBmag6lg-97a+hvJUVRFRtYb_UN1L@aip`Capv!f@nHiVS5&;aXnM!B$46kAgM=9kaK>>hH(Ez0^laS^nV4wQ<2~a+G=YcggmI(%`wACh_^%>%cT>0{-ZuIj>Yh7xw{5CFJkAh9JgH^>gw(`r~~_o*6s4c z>Qax)hx$TV$mU10+cARNI5n{HOs1bfBOO*oV-Q%MN0=7*;02Xt?vZ|>t5rerV>R0~ z+lgJHhobsHS$T$QL#8p|wz`DSQaiYJfFE(Kyo^0NuJBQF5%9LRr2pLo>u||nhmqT2 zY&EV&u{rYOYiepL<>HP*Ic-+?nd#OrhBtX~U&LSkCJF-uWIuRnUBx4vA@2-Ri?4Cf z9+evL^L4H*)|H!*I!B~T&V}62SR0M0mJNoR=$G;t+KZW+v7o>M8ViF;?3TIOw!tzx zLu@cFAS8M|I7)Ud+4xpLK9#YgRcsIyi7sr$TL3U+0i- zj;Zs>$yR$;4^n9ueLG@J+4r8ET4!4e4H7@Poh+a8OfgPkTZMC{6DCj$bV{W}>cKuiE{Uje>~h?%oeXx_YO zN_20`1}7xi@IFn8Wk?LM=w4T7=Xpq1{Ha1DeD4IxyWM8DvYcvoqp9`n|2;AvJv z&hV?+hO}wYNCoM|h-gpo}Nnl4hd^ zt8N*hTtmUxjK17sVryV&J~9%f7EJV093#zVQeeX)w=u_!ijolCN@Fp{h!+So01;9!D!Mpf7rB86Bf*|lOi{` zlD=?V)|F-1@|6eQh(@O@eLbZp%5HBD+j0J?_YP4N5j_k>-v+j`I8o3^zRj_*wl#HO zK9-^K7kBCm|5iJo?EgE<_%k=#l{)imn1+VA2Ge+)N^J^xTun`#pkuCAE0gF}#r!o( zGB(Z7FnQ9P!k7mVapimE29Av^YPGiU39xAJ<_V=#(z_thnn)6T$I?3@3(SAmAw?n> zDM(bkcj+5I|9>zKDCn=ruT6o)VS2w4UfvQY-mu-&L zi2cCuuMPbV?f*#rrw*{>Ie_E0FQAS4&qW;+q(#DW*~jut=uAv3_4=IOxr41KTnFY< zOlitnk;T(P_vPcW-K5TQtj56U8v2RbVZ(&Hh=ElGSJuV@Q$`a;YFoi}OU#Q)F5Gk2 zZv^Yg=%oOn&jjqvq|^A+{kYg6#!AwOI1 z50;No_(WxgG_Cn)UWK!zi$+U*Zsg?7&UTj$Q)g9fNHZxK$6GuU1j69<(|y|K>a{Df zQ>8G0pI9@3SXkN|``A8Ut^aH(697ZDOoTzlWRUp)7!ag%oq+!9k~u&+3Lb^TkpK%8 z=+lw{fzL%HZnKlsskNYZsZq6wDy-Q~j3%DWRrMJxM-D?3G`tf%B60B$2xn*47<=vR zk$+0}Zb9BSIFpJG(b<*y5NdRDBSGCKa3moO6$^hLRla67BMJy#0B!=z0ch{a^6%|| zbBVgTWCjUzBp{gG&lW@Vf8Nk;UlmM9Oi?H$y$6c%Bvnp-0l|9>C|`iK9*5o%=&$h= zlX9ZjDBwt$XH-kOqg&mt`*GlnKxBNy$9#d-f+SQtX}(l{_F>$Fnz7ORtO|Sjj*3b( zc$;0y&ww^m|BOVv>{W3xMCfcFePAPN%as2f2bGF=ba;mkyTlKXHMOns^jtT^ByQ z<0hFcSq$!vO&OA1Hq&R!uwqL!T`2IvCSj8^%%qqV_4T`Iu3L{4n8(v6LN0>gXXp&S zMlz(PxUt>LL3DLp&%l^jM*a&yf>>^j6t55+ed>{kO3|U6**Qh0AoqQFmh6n8wUN;A zl^Aq#@K(_3i_tj7unD)U;;id8%?0D3zt(J{v?M)KVt3SrMrw1#gwLU?a`rF4wRs)b zJ>gTanSlrDin?9~x&HG_d$ZzL>B1H(t$N&Aeqa~$#ECA8T4Mr}nQoCmnWNw^-c>om z2Gd2}=vn(z-yf2CyoIM5%FiqcrSVFtOU|BR^=nv%&h}e;ii_G?okSc)y@N!@;;}G$ znE$(5iV4d}DA5@|A4)snBDLHodtRMNrdfBsGR7Mkn4ADtUgJ{dOdE%aCAY^(Ax;QC zZKhX2mZk{Q_*}nGl3H0GOd6c?bSH~^pe8RnJEQ_t&1ZtZug=JafJJ8{ubUaA4JL}W z@j-6tR>9;fvzeUAbH~81++$+hL{VBhdwZ)X1lLcB(|m5)>|X8iO#~1s(L@o- zZtGeO5&pnh!m*1Ouocb-p=^M}Au={=avwpqMIFF>wRyrHPo$!SgkU>6$=CtcJn?>; z0z89h7W`EQZN`~&M9vugEPPis+2s@d+b9@gJxH!|jg>4r2Ub2?xlt!Q5-mO-C-`{f zsB*=Kt^*Pf`7pqDoQzMe#@Bbl)tOE$E6{<{d7!OdL&C3R-&rT=Y`PkaP;&x5 zbamx7lZ*E>RubHtukUH$bt}w&#kX6_@lw_VYKgenY%rcUcG2JBm@uk#Y z-lUr#lxE&wZP&nbFbPu?RiB-iYOKi~S|vdZZaZ>a!u1MvUEIAQm?EMLvo?Nzi@}D9 z;-GyP;qy#^;hqY+N+c6QB!x~5Q$j6u9ck$ZuNn$OiHs#fy)mswh=VN;9*T!AS9~c9 z*8W6w)GzE0IgS=ittmE{Y+>5P4>iAOrZZdVfJs@JY^+*b%|89AsO2W_X(hkvp2-AG zPxZ%4q`Dr~DW2`#yLU0=;8)ZhC$wiEBAGu>BB$d=8GyYw{&4dc@90Cu5XS`k34@@D z3CU+y*Oj0Gf?dwbBTfxK%`PC~F|s`@i_(J@48-hy3_ZrX^m9_^0)a#EiI|W~k3I?m zQs5sPpAB){1C;ItnOqo54|6a9+|PAo6ts78@&zd26eANy@_T8gn7gxAN_Cp9n37$) z`)fy9fIL@2$2-YyB?Yq}_hJu5&e>zcS@G!$0(4-dte4&uTXRdi)At_2uD=-%dn-7B zUA4fT%Y4oRktS(fsCS1>1{1oFjY-O@t&jON4zla;%`W_eW^(iu-q zHs5~v7n0^{&p|TmpW9wBA}=ba^MeeAu-xx4`m9ZhO`_4JUmMvA3w*oR%==IO6!44l zUD2itmHOl4?P{;~Cku!!!F@>cV`puH17~fGwDi^_j>&7BR2)zS>F2+M5mdqvrxXm! znG`1{1k+129hV>hoxJJibh!$|k1Yk{y-JB-03whQHKGC}6KNponSx>UDWJjMlT}7Z zPmb|V*Dw<@8`0@RnVw=o3MAq-iM7;TnnhD&Gu4%3D&E6i3<9=`UP?->6oKfJxi{X)qEr!V_7la-rTz4G9Y7CM8IHD!VlM*cqpdT

    cIplC%RFh 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 Re%D~nrIRCIdvl| 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

  1. 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

    ml6EAKA4kAW{FX0iUO{YEZ47oR_9yxUtybP`v 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

    Bx^dCHKB&= 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%0AjfCy*2N1KHx?BY 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

    =U-!#FV3}SP}$C!c282W+dPyG2B!q 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%0AXkA5RQTc?Kg{x>~m+uP$W&f?^m%iBoI=h8se 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�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

    V=g+$m+Tpfg;rfMkc^k2ZHpOxxCPQG@O&M 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 z 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 Date: Tue, 26 Oct 2021 00:45:25 +0530 Subject: [PATCH 0210/1164] sentinel: Add SentinelManagedSSLConnection (#1419) Create a simple class which takes in both: - SentinelManaged - SSLConnection in cases where the sentinel is being used with a TLS enabled redis setup. --- redis/sentinel.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/redis/sentinel.py b/redis/sentinel.py index 6456b8e868..740d92f983 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -3,7 +3,7 @@ from redis.client import Redis from redis.commands import SentinelCommands -from redis.connection import ConnectionPool, Connection +from redis.connection import ConnectionPool, Connection, SSLConnection from redis.exceptions import (ConnectionError, ResponseError, ReadOnlyError, TimeoutError) from redis.utils import str_if_bytes @@ -66,6 +66,10 @@ def read_response(self): raise +class SentinelManagedSSLConnection(SentinelManagedConnection, SSLConnection): + pass + + class SentinelConnectionPool(ConnectionPool): """ Sentinel backed connection pool. From 70ef9ec68f9163c86d4cace2941e2f0ae4ce8525 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 26 Oct 2021 00:14:29 -0700 Subject: [PATCH 0211/1164] Convert README & CONTRIBUTING from rst to md (#1633) --- CONTRIBUTING.md | 176 +++++++++ CONTRIBUTING.rst | 137 ------- README.md | 962 ++++++++++++++++++++++++++++++++++++++++++++++ README.rst | 970 ----------------------------------------------- 4 files changed, 1138 insertions(+), 1107 deletions(-) create mode 100644 CONTRIBUTING.md delete mode 100644 CONTRIBUTING.rst create mode 100644 README.md delete mode 100644 README.rst diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..31170f3718 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,176 @@ +# Contributing + +## Introduction + +First off, thank you for considering contributing to redis-py. We value +community contributions! + +## Contributions We Need + +You may already know what you want to contribute \-- a fix for a bug you +encountered, or a new feature your team wants to use. + +If you don\'t know what to contribute, keep an open mind! Improving +documentation, bug triaging, and writing tutorials are all examples of +helpful contributions that mean less work for you. + +## Your First Contribution + +Unsure where to begin contributing? You can start by looking through +[help-wanted +issues](https://github.com/andymccurdy/redis-py/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted). + +Never contributed to open source before? Here are a couple of friendly +tutorials: + +- +- + +## Getting Started + +Here\'s how to get started with your code contribution: + +1. Create your own fork of redis-py +2. Do the changes in your fork +3. + *Create a virtualenv and install the development dependencies from the dev_requirements.txt file:* + + a. python -m venv .venv + b. source .venv/bin/activate + c. pip install -r dev_requirements.txt + +4. If you need a development environment, run `invoke devenv` +5. While developing, make sure the tests pass by running `invoke tests` +6. If you like the change and think the project could use it, send a + pull request + +To see what else is part of the automation, run `invoke -l` + +## The Development Environment + +Running `invoke devenv` installs the development dependencies specified +in the dev_requirements.txt. It starts all of the dockers used by this +project, and leaves them running. These can be easily cleaned up with +`invoke clean`. NOTE: it is assumed that the user running these tests, +can execute docker and its various commands. + +- A master Redis node +- A Redis replica node +- Three sentinel Redis nodes +- A multi-python docker, with your source code mounted in /data + +The replica node, is a replica of the master node, using the +[leader-follower replication](https://redis.io/topics/replication) +feature. + +The sentinels monitor the master node in a [sentinel high-availability +configuration](https://redis.io/topics/sentinel). + +## Testing + +Each run of tox starts and stops the various dockers required. Sometimes +things get stuck, an `invoke clean` can help. + +Continuous Integration uses these same wrappers to run all of these +tests against multiple versions of python. Feel free to test your +changes against all the python versions supported, as declared by the +tox.ini file (eg: tox -e py39). If you have the various python versions +on your desktop, you can run *tox* by itself, to test all supported +versions. Alternatively, as your source code is mounted in the +**lots-of-pythons** docker, you can start exploring from there, with all +supported python versions! + +### Docker Tips + +Following are a few tips that can help you work with the Docker-based +development environment. + +To get a bash shell inside of a container: + +`$ docker run -it /bin/bash` + +**Note**: The term \"service\" refers to the \"services\" defined in the +`tox.ini` file at the top of the repo: \"master\", \"replicaof\", +\"sentinel_1\", \"sentinel_2\", \"sentinel_3\". + +Containers run a minimal Debian image that probably lacks tools you want +to use. To install packages, first get a bash session (see previous tip) +and then run: + +`$ apt update && apt install ` + +You can see the logging output of a containers like this: + +`$ docker logs -f ` + +The command make test runs all tests in all tested Python +environments. To run the tests in a single environment, like Python 3.6, +use a command like this: + +`$ docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9` + +Here, the flag `-e py36` runs tests against the Python 3.6 tox +environment. And note from the example that whenever you run tests like +this, instead of using make test, you need to pass +`-- --redis-url=redis://master:6379/9`. This points the tests at the +\"master\" container. + +Our test suite uses `pytest`. You can run a specific test suite against +a specific Python version like this: + +`$ docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9 tests/test_commands.py` + +### Troubleshooting + +If you get any errors when running `make dev` or `make test`, make sure +that you are using supported versions of Docker. + +Please try at least versions of Docker. + +- Docker 19.03.12 + +## How to Report a Bug + +### Security Vulnerabilities + +**NOTE**: If you find a security vulnerability, do NOT open an issue. +Email Andy McCurdy () instead. + +In order to determine whether you are dealing with a security issue, ask +yourself these two questions: + +- Can I access something that\'s not mine, or something I shouldn\'t + have access to? +- Can I disable something for other people? + +If the answer to either of those two questions are \"yes\", then you\'re +probably dealing with a security issue. Note that even if you answer +\"no\" to both questions, you may still be dealing with a security +issue, so if you\'re unsure, just email Andy at . + +### Everything Else + +When filing an issue, make sure to answer these five questions: + +1. What version of redis-py are you using? +2. What version of redis are you using? +3. What did you do? +4. What did you expect to see? +5. What did you see instead? + +## How to Suggest a Feature or Enhancement + +If you\'d like to contribute a new feature, make sure you check our +issue list to see if someone has already proposed it. Work may already +be under way on the feature you want \-- or we may have rejected a +feature like it already. + +If you don\'t see anything, open a new issue that describes the feature +you would like and how it should work. + +## Code Review Process + +The core team looks at Pull Requests on a regular basis. We will give +feedback as as soon as possible. After feedback, we expect a response +within two weeks. After that time, we may close your PR if it isn\'t +showing any activity. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index ba1ddfc5cc..0000000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,137 +0,0 @@ -Contributing -============ - -Introduction ------------- - -First off, thank you for considering contributing to redis-py. We value community contributions! - -Contributions We Need ----------------------- - -You may already know what you want to contribute -- a fix for a bug you encountered, or a new feature your team wants to use. - -If you don't know what to contribute, keep an open mind! Improving documentation, bug triaging, and writing tutorials are all examples of helpful contributions that mean less work for you. - -Your First Contribution ------------------------ -Unsure where to begin contributing? You can start by looking through `help-wanted issues `_. - -Never contributed to open source before? Here are a couple of friendly tutorials: - -- http://makeapullrequest.com/ -- http://www.firsttimersonly.com/ - -Getting Started ---------------- - -Here's how to get started with your code contribution: - -1. Create your own fork of redis-py -2. Do the changes in your fork -3. Create a virtualenv and install the development dependencies from the dev_requirements.txt file: - a. python -m venv .venv - b. source .venv/bin/activate - c. pip install -r dev_requirements.txt -3. If you need a development environment, run ``invoke devenv`` -4. While developing, make sure the tests pass by running ``invoke tests`` -5. If you like the change and think the project could use it, send a pull request - -To see what else is part of the automation, run ``invoke -l`` - -The Development Environment ---------------------------- - -Running ``invoke devenv`` installs the development dependencies specified in the dev_requirements.txt. It starts all of the dockers used by this project, and leaves them running. These can be easily cleaned up with ``invoke clean``. NOTE: it is assumed that the user running these tests, can execute docker and its various commands. - -* A master Redis node -* A Redis replica node -* Three sentinel Redis nodes -* A multi-python docker, with your source code mounted in /data - -The replica node, is a replica of the master node, using the `leader-follower replication `_ feature. - -The sentinels monitor the master node in a `sentinel high-availability configuration `_. - -Testing -------- - -Each run of tox starts and stops the various dockers required. Sometimes things get stuck, an ``invoke clean`` can help. - -Continuous Integration uses these same wrappers to run all of these tests against multiple versions of python. Feel free to test your changes against all the python versions supported, as declared by the tox.ini file (eg: tox -e py39). If you have the various python versions on your desktop, you can run *tox* by itself, to test all supported versions. Alternatively, as your source code is mounted in the **lots-of-pythons** docker, you can start exploring from there, with all supported python versions! - -Docker Tips -^^^^^^^^^^^ - -Following are a few tips that can help you work with the Docker-based development environment. - -To get a bash shell inside of a container: - -``$ docker run -it /bin/bash`` - -**Note**: The term "service" refers to the "services" defined in the ``tox.ini`` file at the top of the repo: "master", "replicaof", "sentinel_1", "sentinel_2", "sentinel_3". - -Containers run a minimal Debian image that probably lacks tools you want to use. To install packages, first get a bash session (see previous tip) and then run: - -``$ apt update && apt install `` - -You can see the logging output of a containers like this: - -``$ docker logs -f `` - -The command `make test` runs all tests in all tested Python environments. To run the tests in a single environment, like Python 3.6, use a command like this: - -``$ docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9`` - -Here, the flag ``-e py36`` runs tests against the Python 3.6 tox environment. And note from the example that whenever you run tests like this, instead of using `make test`, you need to pass ``-- --redis-url=redis://master:6379/9``. This points the tests at the "master" container. - -Our test suite uses ``pytest``. You can run a specific test suite against a specific Python version like this: - -``$ docker-compose run test tox -e py36 -- --redis-url=redis://master:6379/9 tests/test_commands.py`` - -Troubleshooting -^^^^^^^^^^^^^^^ -If you get any errors when running ``make dev`` or ``make test``, make sure that you -are using supported versions of Docker. - -Please try at least versions of Docker. - -* Docker 19.03.12 - -How to Report a Bug -------------------- - -Security Vulnerabilities -^^^^^^^^^^^^^^^^^^^^^^^^ - -**NOTE**: If you find a security vulnerability, do NOT open an issue. Email Andy McCurdy (sedrik@gmail.com) instead. - -In order to determine whether you are dealing with a security issue, ask yourself these two questions: - -* Can I access something that's not mine, or something I shouldn't have access to? -* Can I disable something for other people? - -If the answer to either of those two questions are "yes", then you're probably dealing with a security issue. Note that even if you answer "no" to both questions, you may still be dealing with a security issue, so if you're unsure, just email Andy at sedrik@gmail.com. - -Everything Else -^^^^^^^^^^^^^^^ - -When filing an issue, make sure to answer these five questions: - -1. What version of redis-py are you using? -2. What version of redis are you using? -3. What did you do? -4. What did you expect to see? -5. What did you see instead? - -How to Suggest a Feature or Enhancement ---------------------------------------- - -If you'd like to contribute a new feature, make sure you check our issue list to see if someone has already proposed it. Work may already be under way on the feature you want -- or we may have rejected a feature like it already. - -If you don't see anything, open a new issue that describes the feature you would like and how it should work. - -Code Review Process -------------------- - -The core team looks at Pull Requests on a regular basis. We will give feedback as as soon as possible. After feedback, we expect a response within two weeks. After that time, we may close your PR if it isn't showing any activity. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..1b535ae36f --- /dev/null +++ b/README.md @@ -0,0 +1,962 @@ +# redis-py + +The Python interface to the Redis key-value store. + +[![image](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster) +[![image](https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) +[![image](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/) +[![image](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg)](https://codecov.io/gh/redis/redis-py) + +## Python 2 Compatibility Note + +redis-py 3.5.x will be the last version of redis-py that supports Python +2. The 3.5.x line will continue to get bug fixes and security patches +that support Python 2 until August 1, 2020. redis-py 4.0 will be the +next major version and will require Python 3.5+. + +## Installation + +redis-py requires a running Redis server. See [Redis\'s +quickstart](https://redis.io/topics/quickstart) for installation +instructions. + +redis-py can be installed using pip similar to other +Python packages. Do not use sudo with pip. +It is usually good to work in a +[virtualenv](https://virtualenv.pypa.io/en/latest/) or +[venv](https://docs.python.org/3/library/venv.html) to avoid conflicts +with other package managers and Python projects. For a quick +introduction see [Python Virtual Environments in Five +Minutes](https://bit.ly/py-env). + +To install redis-py, simply: + +``` bash +$ pip install redis +``` + +or from source: + +``` bash +$ python setup.py install +``` + +## Contributing + +Want to contribute a feature, bug report, or report an issue? Check out +our [guide to +contributing](https://github.com/redis/redis-py/blob/master/CONTRIBUTING.rst). + +## Getting Started + +``` pycon +>>> import redis +>>> r = redis.Redis(host='localhost', port=6379, db=0) +>>> r.set('foo', 'bar') +True +>>> r.get('foo') +b'bar' +``` + +By default, all responses are returned as bytes in Python +3 and str in Python 2. The user is responsible for +decoding to Python 3 strings or Python 2 unicode objects. + +If **all** string responses from a client should be decoded, the user +can specify decode_responses=True to +Redis.\_\_init\_\_. In this case, any Redis command that +returns a string type will be decoded with the encoding +specified. + +The default encoding is \"utf-8\", but this can be customized with the +encoding argument to the redis.Redis class. +The encoding will be used to automatically encode any +strings passed to commands, such as key names and values. When +decode_responses=True, string data returned from commands +will be decoded with the same encoding. + +## Upgrading from redis-py 2.X to 3.0 + +redis-py 3.0 introduces many new features but required a number of +backwards incompatible changes to be made in the process. This section +attempts to provide an upgrade path for users migrating from 2.X to 3.0. + +### Python Version Support + +redis-py supports Python 3.5+. + +### Client Classes: Redis and StrictRedis + +redis-py 3.0 drops support for the legacy \"Redis\" client class. +\"StrictRedis\" has been renamed to \"Redis\" and an alias named +\"StrictRedis\" is provided so that users previously using +\"StrictRedis\" can continue to run unchanged. + +The 2.X \"Redis\" class provided alternative implementations of a few +commands. This confused users (rightfully so) and caused a number of +support issues. To make things easier going forward, it was decided to +drop support for these alternate implementations and instead focus on a +single client class. + +2.X users that are already using StrictRedis don\'t have to change the +class name. StrictRedis will continue to work for the foreseeable +future. + +2.X users that are using the Redis class will have to make changes if +they use any of the following commands: + +- SETEX: The argument order has changed. The new order is (name, time, + value). +- LREM: The argument order has changed. The new order is (name, num, + value). +- TTL and PTTL: The return value is now always an int and matches the + official Redis command (>0 indicates the timeout, -1 indicates that + the key exists but that it has no expire time set, -2 indicates that + the key does not exist) + +### SSL Connections + +redis-py 3.0 changes the default value of the +ssl_cert_reqs option from None to +\'required\'. See [Issue +1016](https://github.com/redis/redis-py/issues/1016). This change +enforces hostname validation when accepting a cert from a remote SSL +terminator. If the terminator doesn\'t properly set the hostname on the +cert this will cause redis-py 3.0 to raise a ConnectionError. + +This check can be disabled by setting ssl_cert_reqs to +None. Note that doing so removes the security check. Do so +at your own risk. + +Example with hostname verification using a local certificate bundle +(linux): + +``` pycon +>>> import redis +>>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, + ssl_ca_certs='/etc/ssl/certs/ca-certificates.crt') +>>> r.set('foo', 'bar') +True +>>> r.get('foo') +b'bar' +``` + +Example with hostname verification using +[certifi](https://pypi.org/project/certifi/): + +``` pycon +>>> import redis, certifi +>>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, ssl_ca_certs=certifi.where()) +>>> r.set('foo', 'bar') +True +>>> r.get('foo') +b'bar' +``` + +Example turning off hostname verification (not recommended): + +``` pycon +>>> import redis +>>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, ssl_cert_reqs=None) +>>> r.set('foo', 'bar') +True +>>> r.get('foo') +b'bar' +``` + +### MSET, MSETNX and ZADD + +These commands all accept a mapping of key/value pairs. In redis-py 2.X +this mapping could be specified as `*args` or as `**kwargs`. Both of +these styles caused issues when Redis introduced optional flags to ZADD. +Relying on `*args` caused issues with the optional argument order, +especially in Python 2.7. Relying on `**kwargs` caused potential +collision issues of user keys with the argument names in the method +signature. + +To resolve this, redis-py 3.0 has changed these three commands to all +accept a single positional argument named mapping that is expected to be +a dict. For MSET and MSETNX, the dict is a mapping of key-names -\> +values. For ZADD, the dict is a mapping of element-names -\> score. + +MSET, MSETNX and ZADD now look like: + +``` pycon +def mset(self, mapping): +def msetnx(self, mapping): +def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False): +``` + +All 2.X users that use these commands must modify their code to supply +keys and values as a dict to these commands. + +### ZINCRBY + +redis-py 2.X accidentally modified the argument order of ZINCRBY, +swapping the order of value and amount. ZINCRBY now looks like: + +``` python +def zincrby(self, name, amount, value): +``` + +All 2.X users that rely on ZINCRBY must swap the order of amount and +value for the command to continue to work as intended. + +### Encoding of User Input + +redis-py 3.0 only accepts user data as bytes, strings or numbers (ints, +longs and floats). Attempting to specify a key or a value as any other +type will raise a DataError exception. + +redis-py 2.X attempted to coerce any type of input into a string. While +occasionally convenient, this caused all sorts of hidden errors when +users passed boolean values (which were coerced to \'True\' or +\'False\'), a None value (which was coerced to \'None\') or other +values, such as user defined types. + +All 2.X users should make sure that the keys and values they pass into +redis-py are either bytes, strings or numbers. + +### Locks + +redis-py 3.0 drops support for the pipeline-based Lock and now only +supports the Lua-based lock. In doing so, LuaLock has been renamed to +Lock. This also means that redis-py Lock objects require Redis server +2.6 or greater. + +2.X users that were explicitly referring to \"LuaLock\" will have to now +refer to \"Lock\" instead. + +### Locks as Context Managers + +redis-py 3.0 now raises a LockError when using a lock as a context +manager and the lock cannot be acquired within the specified timeout. +This is more of a bug fix than a backwards incompatible change. However, +given an error is now raised where none was before, this might alarm +some users. + +2.X users should make sure they\'re wrapping their lock code in a +try/catch like this: + +``` python +try: + with r.lock('my-lock-key', blocking_timeout=5) as lock: + # code you want executed only after the lock has been acquired +except LockError: + # the lock wasn't acquired +``` + +## API Reference + +The [official Redis command documentation](https://redis.io/commands) +does a great job of explaining each command in detail. redis-py attempts +to adhere to the official command syntax. There are a few exceptions: + +- **SELECT**: Not implemented. See the explanation in the Thread + Safety section below. +- **DEL**: \'del\' is a reserved keyword in the Python syntax. + Therefore redis-py uses \'delete\' instead. +- **MULTI/EXEC**: These are implemented as part of the Pipeline class. + The pipeline is wrapped with the MULTI and EXEC statements by + default when it is executed, which can be disabled by specifying + transaction=False. See more about Pipelines below. +- **SUBSCRIBE/LISTEN**: Similar to pipelines, PubSub is implemented as + a separate class as it places the underlying connection in a state + where it can\'t execute non-pubsub commands. Calling the pubsub + method from the Redis client will return a PubSub instance where you + can subscribe to channels and listen for messages. You can only call + PUBLISH from the Redis client (see [this comment on issue + #151](https://github.com/redis/redis-py/issues/151#issuecomment-1545015) + for details). +- **SCAN/SSCAN/HSCAN/ZSCAN**: The \*SCAN commands are implemented as + they exist in the Redis documentation. In addition, each command has + an equivalent iterator method. These are purely for convenience so + the user doesn\'t have to keep track of the cursor while iterating. + Use the scan_iter/sscan_iter/hscan_iter/zscan_iter methods for this + behavior. + +## More Detail + +### Connection Pools + +Behind the scenes, redis-py uses a connection pool to manage connections +to a Redis server. By default, each Redis instance you create will in +turn create its own connection pool. You can override this behavior and +use an existing connection pool by passing an already created connection +pool instance to the connection_pool argument of the Redis class. You +may choose to do this in order to implement client side sharding or have +fine-grain control of how connections are managed. + +``` pycon +>>> pool = redis.ConnectionPool(host='localhost', port=6379, db=0) +>>> r = redis.Redis(connection_pool=pool) +``` + +### Connections + +ConnectionPools manage a set of Connection instances. redis-py ships +with two types of Connections. The default, Connection, is a normal TCP +socket based connection. The UnixDomainSocketConnection allows for +clients running on the same device as the server to connect via a unix +domain socket. To use a UnixDomainSocketConnection connection, simply +pass the unix_socket_path argument, which is a string to the unix domain +socket file. Additionally, make sure the unixsocket parameter is defined +in your redis.conf file. It\'s commented out by default. + +``` pycon +>>> r = redis.Redis(unix_socket_path='/tmp/redis.sock') +``` + +You can create your own Connection subclasses as well. This may be +useful if you want to control the socket behavior within an async +framework. To instantiate a client class using your own connection, you +need to create a connection pool, passing your class to the +connection_class argument. Other keyword parameters you pass to the pool +will be passed to the class specified during initialization. + +``` pycon +>>> pool = redis.ConnectionPool(connection_class=YourConnectionClass, + your_arg='...', ...) +``` + +Connections maintain an open socket to the Redis server. Sometimes these +sockets are interrupted or disconnected for a variety of reasons. For +example, network appliances, load balancers and other services that sit +between clients and servers are often configured to kill connections +that remain idle for a given threshold. + +When a connection becomes disconnected, the next command issued on that +connection will fail and redis-py will raise a ConnectionError to the +caller. This allows each application that uses redis-py to handle errors +in a way that\'s fitting for that specific application. However, +constant error handling can be verbose and cumbersome, especially when +socket disconnections happen frequently in many production environments. + +To combat this, redis-py can issue regular health checks to assess the +liveliness of a connection just before issuing a command. Users can pass +`health_check_interval=N` to the Redis or ConnectionPool classes or as a +query argument within a Redis URL. The value of `health_check_interval` +must be an integer. A value of `0`, the default, disables health checks. +Any positive integer will enable health checks. Health checks are +performed just before a command is executed if the underlying connection +has been idle for more than `health_check_interval` seconds. For +example, `health_check_interval=30` will ensure that a health check is +run on any connection that has been idle for 30 or more seconds just +before a command is executed on that connection. + +If your application is running in an environment that disconnects idle +connections after 30 seconds you should set the `health_check_interval` +option to a value less than 30. + +This option also works on any PubSub connection that is created from a +client with `health_check_interval` enabled. PubSub users need to ensure +that `get_message()` or `listen()` are called more frequently than +`health_check_interval` seconds. It is assumed that most workloads +already do this. + +If your PubSub use case doesn\'t call `get_message()` or `listen()` +frequently, you should call `pubsub.check_health()` explicitly on a +regularly basis. + +### Parsers + +Parser classes provide a way to control how responses from the Redis +server are parsed. redis-py ships with two parser classes, the +PythonParser and the HiredisParser. By default, redis-py will attempt to +use the HiredisParser if you have the hiredis module installed and will +fallback to the PythonParser otherwise. + +Hiredis is a C library maintained by the core Redis team. Pieter +Noordhuis was kind enough to create Python bindings. Using Hiredis can +provide up to a 10x speed improvement in parsing responses from the +Redis server. The performance increase is most noticeable when +retrieving many pieces of data, such as from LRANGE or SMEMBERS +operations. + +Hiredis is available on PyPI, and can be installed via pip just like +redis-py. + +``` bash +$ pip install hiredis +``` + +### Response Callbacks + +The client class uses a set of callbacks to cast Redis responses to the +appropriate Python type. There are a number of these callbacks defined +on the Redis client class in a dictionary called RESPONSE_CALLBACKS. + +Custom callbacks can be added on a per-instance basis using the +set_response_callback method. This method accepts two arguments: a +command name and the callback. Callbacks added in this manner are only +valid on the instance the callback is added to. If you want to define or +override a callback globally, you should make a subclass of the Redis +client and add your callback to its RESPONSE_CALLBACKS class dictionary. + +Response callbacks take at least one parameter: the response from the +Redis server. Keyword arguments may also be accepted in order to further +control how to interpret the response. These keyword arguments are +specified during the command\'s call to execute_command. The ZRANGE +implementation demonstrates the use of response callback keyword +arguments with its \"withscores\" argument. + +### Thread Safety + +Redis client instances can safely be shared between threads. Internally, +connection instances are only retrieved from the connection pool during +command execution, and returned to the pool directly after. Command +execution never modifies state on the client instance. + +However, there is one caveat: the Redis SELECT command. The SELECT +command allows you to switch the database currently in use by the +connection. That database remains selected until another is selected or +until the connection is closed. This creates an issue in that +connections could be returned to the pool that are connected to a +different database. + +As a result, redis-py does not implement the SELECT command on client +instances. If you use multiple Redis databases within the same +application, you should create a separate client instance (and possibly +a separate connection pool) for each database. + +It is not safe to pass PubSub or Pipeline objects between threads. + +### Pipelines + +Pipelines are a subclass of the base Redis class that provide support +for buffering multiple commands to the server in a single request. They +can be used to dramatically increase the performance of groups of +commands by reducing the number of back-and-forth TCP packets between +the client and server. + +Pipelines are quite simple to use: + +``` pycon +>>> r = redis.Redis(...) +>>> r.set('bing', 'baz') +>>> # Use the pipeline() method to create a pipeline instance +>>> pipe = r.pipeline() +>>> # The following SET commands are buffered +>>> pipe.set('foo', 'bar') +>>> pipe.get('bing') +>>> # the EXECUTE call sends all buffered commands to the server, returning +>>> # a list of responses, one for each command. +>>> pipe.execute() +[True, b'baz'] +``` + +For ease of use, all commands being buffered into the pipeline return +the pipeline object itself. Therefore calls can be chained like: + +``` pycon +>>> pipe.set('foo', 'bar').sadd('faz', 'baz').incr('auto_number').execute() +[True, True, 6] +``` + +In addition, pipelines can also ensure the buffered commands are +executed atomically as a group. This happens by default. If you want to +disable the atomic nature of a pipeline but still want to buffer +commands, you can turn off transactions. + +``` pycon +>>> pipe = r.pipeline(transaction=False) +``` + +A common issue occurs when requiring atomic transactions but needing to +retrieve values in Redis prior for use within the transaction. For +instance, let\'s assume that the INCR command didn\'t exist and we need +to build an atomic version of INCR in Python. + +The completely naive implementation could GET the value, increment it in +Python, and SET the new value back. However, this is not atomic because +multiple clients could be doing this at the same time, each getting the +same value from GET. + +Enter the WATCH command. WATCH provides the ability to monitor one or +more keys prior to starting a transaction. If any of those keys change +prior the execution of that transaction, the entire transaction will be +canceled and a WatchError will be raised. To implement our own +client-side INCR command, we could do something like this: + +``` pycon +>>> with r.pipeline() as pipe: +... while True: +... try: +... # put a WATCH on the key that holds our sequence value +... pipe.watch('OUR-SEQUENCE-KEY') +... # after WATCHing, the pipeline is put into immediate execution +... # mode until we tell it to start buffering commands again. +... # this allows us to get the current value of our sequence +... current_value = pipe.get('OUR-SEQUENCE-KEY') +... next_value = int(current_value) + 1 +... # now we can put the pipeline back into buffered mode with MULTI +... pipe.multi() +... pipe.set('OUR-SEQUENCE-KEY', next_value) +... # and finally, execute the pipeline (the set command) +... pipe.execute() +... # if a WatchError wasn't raised during execution, everything +... # we just did happened atomically. +... break +... except WatchError: +... # another client must have changed 'OUR-SEQUENCE-KEY' between +... # the time we started WATCHing it and the pipeline's execution. +... # our best bet is to just retry. +... continue +``` + +Note that, because the Pipeline must bind to a single connection for the +duration of a WATCH, care must be taken to ensure that the connection is +returned to the connection pool by calling the reset() method. If the +Pipeline is used as a context manager (as in the example above) reset() +will be called automatically. Of course you can do this the manual way +by explicitly calling reset(): + +``` pycon +>>> pipe = r.pipeline() +>>> while True: +... try: +... pipe.watch('OUR-SEQUENCE-KEY') +... ... +... pipe.execute() +... break +... except WatchError: +... continue +... finally: +... pipe.reset() +``` + +A convenience method named \"transaction\" exists for handling all the +boilerplate of handling and retrying watch errors. It takes a callable +that should expect a single parameter, a pipeline object, and any number +of keys to be WATCHed. Our client-side INCR command above can be written +like this, which is much easier to read: + +``` pycon +>>> def client_side_incr(pipe): +... current_value = pipe.get('OUR-SEQUENCE-KEY') +... next_value = int(current_value) + 1 +... pipe.multi() +... pipe.set('OUR-SEQUENCE-KEY', next_value) +>>> +>>> r.transaction(client_side_incr, 'OUR-SEQUENCE-KEY') +[True] +``` + +Be sure to call pipe.multi() in the callable passed to +Redis.transaction prior to any write commands. + +### Publish / Subscribe + +redis-py includes a PubSub object that subscribes to +channels and listens for new messages. Creating a PubSub +object is easy. + +``` pycon +>>> r = redis.Redis(...) +>>> p = r.pubsub() +``` + +Once a PubSub instance is created, channels and patterns +can be subscribed to. + +``` pycon +>>> p.subscribe('my-first-channel', 'my-second-channel', ...) +>>> p.psubscribe('my-*', ...) +``` + +The PubSub instance is now subscribed to those +channels/patterns. The subscription confirmations can be seen by reading +messages from the PubSub instance. + +``` pycon +>>> p.get_message() +{'pattern': None, 'type': 'subscribe', 'channel': b'my-second-channel', 'data': 1} +>>> p.get_message() +{'pattern': None, 'type': 'subscribe', 'channel': b'my-first-channel', 'data': 2} +>>> p.get_message() +{'pattern': None, 'type': 'psubscribe', 'channel': b'my-*', 'data': 3} +``` + +Every message read from a PubSub instance will be a +dictionary with the following keys. + +- **type**: One of the following: \'subscribe\', \'unsubscribe\', + \'psubscribe\', \'punsubscribe\', \'message\', \'pmessage\' +- **channel**: The channel \[un\]subscribed to or the channel a + message was published to +- **pattern**: The pattern that matched a published message\'s + channel. Will be None in all cases except for + \'pmessage\' types. +- **data**: The message data. With \[un\]subscribe messages, this + value will be the number of channels and patterns the connection is + currently subscribed to. With \[p\]message messages, this value will + be the actual published message. + +Let\'s send a message now. + +``` pycon +# the publish method returns the number matching channel and pattern +# subscriptions. 'my-first-channel' matches both the 'my-first-channel' +# subscription and the 'my-*' pattern subscription, so this message will +# be delivered to 2 channels/patterns +>>> r.publish('my-first-channel', 'some data') +2 +>>> p.get_message() +{'channel': b'my-first-channel', 'data': b'some data', 'pattern': None, 'type': 'message'} +>>> p.get_message() +{'channel': b'my-first-channel', 'data': b'some data', 'pattern': b'my-*', 'type': 'pmessage'} +``` + +Unsubscribing works just like subscribing. If no arguments are passed to +\[p\]unsubscribe, all channels or patterns will be unsubscribed from. + +``` pycon +>>> p.unsubscribe() +>>> p.punsubscribe('my-*') +>>> p.get_message() +{'channel': b'my-second-channel', 'data': 2, 'pattern': None, 'type': 'unsubscribe'} +>>> p.get_message() +{'channel': b'my-first-channel', 'data': 1, 'pattern': None, 'type': 'unsubscribe'} +>>> p.get_message() +{'channel': b'my-*', 'data': 0, 'pattern': None, 'type': 'punsubscribe'} +``` + +redis-py also allows you to register callback functions to handle +published messages. Message handlers take a single argument, the +message, which is a dictionary just like the examples above. To +subscribe to a channel or pattern with a message handler, pass the +channel or pattern name as a keyword argument with its value being the +callback function. + +When a message is read on a channel or pattern with a message handler, +the message dictionary is created and passed to the message handler. In +this case, a None value is returned from get_message() +since the message was already handled. + +``` pycon +>>> def my_handler(message): +... print('MY HANDLER: ', message['data']) +>>> p.subscribe(**{'my-channel': my_handler}) +# read the subscribe confirmation message +>>> p.get_message() +{'pattern': None, 'type': 'subscribe', 'channel': b'my-channel', 'data': 1} +>>> r.publish('my-channel', 'awesome data') +1 +# for the message handler to work, we need tell the instance to read data. +# this can be done in several ways (read more below). we'll just use +# the familiar get_message() function for now +>>> message = p.get_message() +MY HANDLER: awesome data +# note here that the my_handler callback printed the string above. +# `message` is None because the message was handled by our handler. +>>> print(message) +None +``` + +If your application is not interested in the (sometimes noisy) +subscribe/unsubscribe confirmation messages, you can ignore them by +passing ignore_subscribe_messages=True to +r.pubsub(). This will cause all subscribe/unsubscribe +messages to be read, but they won\'t bubble up to your application. + +``` pycon +>>> p = r.pubsub(ignore_subscribe_messages=True) +>>> p.subscribe('my-channel') +>>> p.get_message() # hides the subscribe message and returns None +>>> r.publish('my-channel', 'my data') +1 +>>> p.get_message() +{'channel': b'my-channel', 'data': b'my data', 'pattern': None, 'type': 'message'} +``` + +There are three different strategies for reading messages. + +The examples above have been using pubsub.get_message(). +Behind the scenes, get_message() uses the system\'s +\'select\' module to quickly poll the connection\'s socket. If there\'s +data available to be read, get_message() will read it, +format the message and return it or pass it to a message handler. If +there\'s no data to be read, get_message() will +immediately return None. This makes it trivial to integrate into an +existing event loop inside your application. + +``` pycon +>>> while True: +>>> message = p.get_message() +>>> if message: +>>> # do something with the message +>>> time.sleep(0.001) # be nice to the system :) +``` + +Older versions of redis-py only read messages with +pubsub.listen(). listen() is a generator that blocks until +a message is available. If your application doesn\'t need to do anything +else but receive and act on messages received from redis, listen() is an +easy way to get up an running. + +``` pycon +>>> for message in p.listen(): +... # do something with the message +``` + +The third option runs an event loop in a separate thread. +pubsub.run_in_thread() creates a new thread and starts the +event loop. The thread object is returned to the caller of +[un_in_thread(). The caller can use the +thread.stop() method to shut down the event loop and +thread. Behind the scenes, this is simply a wrapper around +get_message() that runs in a separate thread, essentially +creating a tiny non-blocking event loop for you. +run_in_thread() takes an optional sleep_time +argument. If specified, the event loop will call +time.sleep() with the value in each iteration of the loop. + +Note: Since we\'re running in a separate thread, there\'s no way to +handle messages that aren\'t automatically handled with registered +message handlers. Therefore, redis-py prevents you from calling +run_in_thread() if you\'re subscribed to patterns or +channels that don\'t have message handlers attached. + +``` pycon +>>> p.subscribe(**{'my-channel': my_handler}) +>>> thread = p.run_in_thread(sleep_time=0.001) +# the event loop is now running in the background processing messages +# when it's time to shut it down... +>>> thread.stop() +``` + +run_in_thread also supports an optional exception handler, +which lets you catch exceptions that occur within the worker thread and +handle them appropriately. The exception handler will take as arguments +the exception itself, the pubsub object, and the worker thread returned +by run_in_thread. + +``` pycon +>>> p.subscribe(**{'my-channel': my_handler}) +>>> def exception_handler(ex, pubsub, thread): +>>> print(ex) +>>> thread.stop() +>>> thread.join(timeout=1.0) +>>> pubsub.close() +>>> thread = p.run_in_thread(exception_handler=exception_handler) +``` + +A PubSub object adheres to the same encoding semantics as the client +instance it was created from. Any channel or pattern that\'s unicode +will be encoded using the charset specified on the client +before being sent to Redis. If the client\'s +decode_responses flag is set the False (the default), the +\'channel\', \'pattern\' and \'data\' values in message dictionaries +will be byte strings (str on Python 2, bytes on Python 3). If the +client\'s decode_responses is True, then the \'channel\', +\'pattern\' and \'data\' values will be automatically decoded to unicode +strings using the client\'s charset. + +PubSub objects remember what channels and patterns they are subscribed +to. In the event of a disconnection such as a network error or timeout, +the PubSub object will re-subscribe to all prior channels and patterns +when reconnecting. Messages that were published while the client was +disconnected cannot be delivered. When you\'re finished with a PubSub +object, call its .close() method to shutdown the +connection. + +``` pycon +>>> p = r.pubsub() +>>> ... +>>> p.close() +``` + +The PUBSUB set of subcommands CHANNELS, NUMSUB and NUMPAT are also +supported: + +``` pycon +>>> r.pubsub_channels() +[b'foo', b'bar'] +>>> r.pubsub_numsub('foo', 'bar') +[(b'foo', 9001), (b'bar', 42)] +>>> r.pubsub_numsub('baz') +[(b'baz', 0)] +>>> r.pubsub_numpat() +1204 +``` + +### Monitor + +redis-py includes a Monitor object that streams every +command processed by the Redis server. Use listen() on the +Monitor object to block until a command is received. + +``` pycon +>>> r = redis.Redis(...) +>>> with r.monitor() as m: +>>> for command in m.listen(): +>>> print(command) +``` + +### Lua Scripting + +redis-py supports the EVAL, EVALSHA, and SCRIPT commands. However, there +are a number of edge cases that make these commands tedious to use in +real world scenarios. Therefore, redis-py exposes a Script object that +makes scripting much easier to use. + +To create a Script instance, use the register_script +function on a client instance passing the Lua code as the first +argument. register_script returns a Script instance that +you can use throughout your code. + +The following trivial Lua script accepts two parameters: the name of a +key and a multiplier value. The script fetches the value stored in the +key, multiplies it with the multiplier value and returns the result. + +``` pycon +>>> r = redis.Redis() +>>> lua = """ +... local value = redis.call('GET', KEYS[1]) +... value = tonumber(value) +... return value * ARGV[1]""" +>>> multiply = r.register_script(lua) +``` + +multiply is now a Script instance that is invoked by +calling it like a function. Script instances accept the following +optional arguments: + +- **keys**: A list of key names that the script will access. This + becomes the KEYS list in Lua. +- **args**: A list of argument values. This becomes the ARGV list in + Lua. +- **client**: A redis-py Client or Pipeline instance that will invoke + the script. If client isn\'t specified, the client that initially + created the Script instance (the one that + register_script was invoked from) will be used. + +Continuing the example from above: + +``` pycon +>>> r.set('foo', 2) +>>> multiply(keys=['foo'], args=[5]) +10 +``` + +The value of key \'foo\' is set to 2. When multiply is invoked, the +\'foo\' key is passed to the script along with the multiplier value of +5. Lua executes the script and returns the result, 10. + +Script instances can be executed using a different client instance, even +one that points to a completely different Redis server. + +``` pycon +>>> r2 = redis.Redis('redis2.example.com') +>>> r2.set('foo', 3) +>>> multiply(keys=['foo'], args=[5], client=r2) +15 +``` + +The Script object ensures that the Lua script is loaded into Redis\'s +script cache. In the event of a NOSCRIPT error, it will load the script +and retry executing it. + +Script objects can also be used in pipelines. The pipeline instance +should be passed as the client argument when calling the script. Care is +taken to ensure that the script is registered in Redis\'s script cache +just prior to pipeline execution. + +``` pycon +>>> pipe = r.pipeline() +>>> pipe.set('foo', 5) +>>> multiply(keys=['foo'], args=[5], client=pipe) +>>> pipe.execute() +[True, 25] +``` + +### Sentinel support + +redis-py can be used together with [Redis +Sentinel](https://redis.io/topics/sentinel) to discover Redis nodes. You +need to have at least one Sentinel daemon running in order to use +redis-py\'s Sentinel support. + +Connecting redis-py to the Sentinel instance(s) is easy. You can use a +Sentinel connection to discover the master and slaves network addresses: + +``` pycon +>>> from redis.sentinel import Sentinel +>>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) +>>> sentinel.discover_master('mymaster') +('127.0.0.1', 6379) +>>> sentinel.discover_slaves('mymaster') +[('127.0.0.1', 6380)] +``` + +You can also create Redis client connections from a Sentinel instance. +You can connect to either the master (for write operations) or a slave +(for read-only operations). + +``` pycon +>>> master = sentinel.master_for('mymaster', socket_timeout=0.1) +>>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1) +>>> master.set('foo', 'bar') +>>> slave.get('foo') +b'bar' +``` + +The master and slave objects are normal Redis instances with their +connection pool bound to the Sentinel instance. When a Sentinel backed +client attempts to establish a connection, it first queries the Sentinel +servers to determine an appropriate host to connect to. If no server is +found, a MasterNotFoundError or SlaveNotFoundError is raised. Both +exceptions are subclasses of ConnectionError. + +When trying to connect to a slave client, the Sentinel connection pool +will iterate over the list of slaves until it finds one that can be +connected to. If no slaves can be connected to, a connection will be +established with the master. + +See [Guidelines for Redis clients with support for Redis +Sentinel](https://redis.io/topics/sentinel-clients) to learn more about +Redis Sentinel. + +### Scan Iterators + +The \*SCAN commands introduced in Redis 2.8 can be cumbersome to use. +While these commands are fully supported, redis-py also exposes the +following methods that return Python iterators for convenience: +scan_iter, hscan_iter, +sscan_iter and zscan_iter. + +``` pycon +>>> for key, value in (('A', '1'), ('B', '2'), ('C', '3')): +... r.set(key, value) +>>> for key in r.scan_iter(): +... print(key, r.get(key)) +A 1 +B 2 +C 3 +``` + +### Cluster Mode + +redis-py does not currently support [Cluster +Mode](https://redis.io/topics/cluster-tutorial). + +### Author + +redis-py is developed and maintained by Andy McCurdy +(). It can be found here: + + +Special thanks to: + +- Ludovico Magnocavallo, author of the original Python Redis client, + from which some of the socket code is still used. +- Alexander Solovyov for ideas on the generic response callback + system. +- Paul Hubbard for initial packaging support. + +### Sponsored by + +[![Redis](./docs/logo-redis.png)](https://www.redis.com) diff --git a/README.rst b/README.rst deleted file mode 100644 index 821a25386d..0000000000 --- a/README.rst +++ /dev/null @@ -1,970 +0,0 @@ -redis-py -======== - -The Python interface to the Redis key-value store. - -.. image:: https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master - :target: https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster -.. image:: https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat - :target: https://redis-py.readthedocs.io/en/stable/ -.. image:: https://badge.fury.io/py/redis.svg - :target: https://pypi.org/project/redis/ -.. image:: https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg - :target: https://codecov.io/gh/redis/redis-py - - -Python 2 Compatibility Note ---------------------------- - -redis-py 3.5.x will be the last version of redis-py that supports Python 2. -The 3.5.x line will continue to get bug fixes and security patches that -support Python 2 until August 1, 2020. redis-py 4.0 will be the next major -version and will require Python 3.5+. - - -Installation ------------- - -redis-py requires a running Redis server. See `Redis's quickstart -`_ for installation instructions. - -redis-py can be installed using `pip` similar to other Python packages. Do not use `sudo` -with `pip`. It is usually good to work in a -`virtualenv `_ or -`venv `_ to avoid conflicts with other package -managers and Python projects. For a quick introduction see -`Python Virtual Environments in Five Minutes `_. - -To install redis-py, simply: - -.. code-block:: bash - - $ pip install redis - -or from source: - -.. code-block:: bash - - $ python setup.py install - -Contributing ------------- - -Want to contribute a feature, bug report, or report an issue? Check out our `guide to -contributing `_. - - -Getting Started ---------------- - -.. code-block:: pycon - - >>> import redis - >>> r = redis.Redis(host='localhost', port=6379, db=0) - >>> r.set('foo', 'bar') - True - >>> r.get('foo') - b'bar' - -By default, all responses are returned as `bytes` in Python 3 and `str` in -Python 2. The user is responsible for decoding to Python 3 strings or Python 2 -unicode objects. - -If **all** string responses from a client should be decoded, the user can -specify `decode_responses=True` to `Redis.__init__`. In this case, any -Redis command that returns a string type will be decoded with the `encoding` -specified. - -The default encoding is "utf-8", but this can be customized with the `encoding` -argument to the `redis.Redis` class. The `encoding` will be used to -automatically encode any strings passed to commands, such as key names and -values. When `decode_responses=True`, string data returned from commands -will be decoded with the same `encoding`. - - -Upgrading from redis-py 2.X to 3.0 ----------------------------------- - -redis-py 3.0 introduces many new features but required a number of backwards -incompatible changes to be made in the process. This section attempts to -provide an upgrade path for users migrating from 2.X to 3.0. - - -Python Version Support -^^^^^^^^^^^^^^^^^^^^^^ - -redis-py supports Python 3.5+. - - -Client Classes: Redis and StrictRedis -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -redis-py 3.0 drops support for the legacy "Redis" client class. "StrictRedis" -has been renamed to "Redis" and an alias named "StrictRedis" is provided so -that users previously using "StrictRedis" can continue to run unchanged. - -The 2.X "Redis" class provided alternative implementations of a few commands. -This confused users (rightfully so) and caused a number of support issues. To -make things easier going forward, it was decided to drop support for these -alternate implementations and instead focus on a single client class. - -2.X users that are already using StrictRedis don't have to change the class -name. StrictRedis will continue to work for the foreseeable future. - -2.X users that are using the Redis class will have to make changes if they -use any of the following commands: - -* SETEX: The argument order has changed. The new order is (name, time, value). -* LREM: The argument order has changed. The new order is (name, num, value). -* TTL and PTTL: The return value is now always an int and matches the - official Redis command (>0 indicates the timeout, -1 indicates that the key - exists but that it has no expire time set, -2 indicates that the key does - not exist) - - -SSL Connections -^^^^^^^^^^^^^^^ - -redis-py 3.0 changes the default value of the `ssl_cert_reqs` option from -`None` to `'required'`. See -`Issue 1016 `_. This -change enforces hostname validation when accepting a cert from a remote SSL -terminator. If the terminator doesn't properly set the hostname on the cert -this will cause redis-py 3.0 to raise a ConnectionError. - -This check can be disabled by setting `ssl_cert_reqs` to `None`. Note that -doing so removes the security check. Do so at your own risk. - -Example with hostname verification using a local certificate bundle (linux): - -.. code-block:: pycon - - >>> import redis - >>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, - ssl=True, - ssl_ca_certs='/etc/ssl/certs/ca-certificates.crt') - >>> r.set('foo', 'bar') - True - >>> r.get('foo') - b'bar' - -Example with hostname verification using -`certifi `_: - -.. code-block:: pycon - - >>> import redis, certifi - >>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, - ssl=True, ssl_ca_certs=certifi.where()) - >>> r.set('foo', 'bar') - True - >>> r.get('foo') - b'bar' - -Example turning off hostname verification (not recommended): - -.. code-block:: pycon - - >>> import redis - >>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, - ssl=True, ssl_cert_reqs=None) - >>> r.set('foo', 'bar') - True - >>> r.get('foo') - b'bar' - - -MSET, MSETNX and ZADD -^^^^^^^^^^^^^^^^^^^^^ - -These commands all accept a mapping of key/value pairs. In redis-py 2.X -this mapping could be specified as ``*args`` or as ``**kwargs``. Both of these -styles caused issues when Redis introduced optional flags to ZADD. Relying on -``*args`` caused issues with the optional argument order, especially in Python -2.7. Relying on ``**kwargs`` caused potential collision issues of user keys with -the argument names in the method signature. - -To resolve this, redis-py 3.0 has changed these three commands to all accept -a single positional argument named mapping that is expected to be a dict. For -MSET and MSETNX, the dict is a mapping of key-names -> values. For ZADD, the -dict is a mapping of element-names -> score. - -MSET, MSETNX and ZADD now look like: - -.. code-block:: pycon - - def mset(self, mapping): - def msetnx(self, mapping): - def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False): - -All 2.X users that use these commands must modify their code to supply -keys and values as a dict to these commands. - - -ZINCRBY -^^^^^^^ - -redis-py 2.X accidentally modified the argument order of ZINCRBY, swapping the -order of value and amount. ZINCRBY now looks like: - -.. code-block:: python - - def zincrby(self, name, amount, value): - -All 2.X users that rely on ZINCRBY must swap the order of amount and value -for the command to continue to work as intended. - - -Encoding of User Input -^^^^^^^^^^^^^^^^^^^^^^ - -redis-py 3.0 only accepts user data as bytes, strings or numbers (ints, longs -and floats). Attempting to specify a key or a value as any other type will -raise a DataError exception. - -redis-py 2.X attempted to coerce any type of input into a string. While -occasionally convenient, this caused all sorts of hidden errors when users -passed boolean values (which were coerced to 'True' or 'False'), a None -value (which was coerced to 'None') or other values, such as user defined -types. - -All 2.X users should make sure that the keys and values they pass into -redis-py are either bytes, strings or numbers. - - -Locks -^^^^^ - -redis-py 3.0 drops support for the pipeline-based Lock and now only supports -the Lua-based lock. In doing so, LuaLock has been renamed to Lock. This also -means that redis-py Lock objects require Redis server 2.6 or greater. - -2.X users that were explicitly referring to "LuaLock" will have to now refer -to "Lock" instead. - - -Locks as Context Managers -^^^^^^^^^^^^^^^^^^^^^^^^^ - -redis-py 3.0 now raises a LockError when using a lock as a context manager and -the lock cannot be acquired within the specified timeout. This is more of a -bug fix than a backwards incompatible change. However, given an error is now -raised where none was before, this might alarm some users. - -2.X users should make sure they're wrapping their lock code in a try/catch -like this: - -.. code-block:: python - - try: - with r.lock('my-lock-key', blocking_timeout=5) as lock: - # code you want executed only after the lock has been acquired - except LockError: - # the lock wasn't acquired - - -API Reference -------------- - -The `official Redis command documentation `_ does a -great job of explaining each command in detail. redis-py attempts to adhere -to the official command syntax. There are a few exceptions: - -* **SELECT**: Not implemented. See the explanation in the Thread Safety section - below. -* **DEL**: 'del' is a reserved keyword in the Python syntax. Therefore redis-py - uses 'delete' instead. -* **MULTI/EXEC**: These are implemented as part of the Pipeline class. The - pipeline is wrapped with the MULTI and EXEC statements by default when it - is executed, which can be disabled by specifying transaction=False. - See more about Pipelines below. -* **SUBSCRIBE/LISTEN**: Similar to pipelines, PubSub is implemented as a separate - class as it places the underlying connection in a state where it can't - execute non-pubsub commands. Calling the pubsub method from the Redis client - will return a PubSub instance where you can subscribe to channels and listen - for messages. You can only call PUBLISH from the Redis client (see - `this comment on issue #151 - `_ - for details). -* **SCAN/SSCAN/HSCAN/ZSCAN**: The \*SCAN commands are implemented as they - exist in the Redis documentation. In addition, each command has an equivalent - iterator method. These are purely for convenience so the user doesn't have - to keep track of the cursor while iterating. Use the - scan_iter/sscan_iter/hscan_iter/zscan_iter methods for this behavior. - - -More Detail ------------ - -Connection Pools -^^^^^^^^^^^^^^^^ - -Behind the scenes, redis-py uses a connection pool to manage connections to -a Redis server. By default, each Redis instance you create will in turn create -its own connection pool. You can override this behavior and use an existing -connection pool by passing an already created connection pool instance to the -connection_pool argument of the Redis class. You may choose to do this in order -to implement client side sharding or have fine-grain control of how -connections are managed. - -.. code-block:: pycon - - >>> pool = redis.ConnectionPool(host='localhost', port=6379, db=0) - >>> r = redis.Redis(connection_pool=pool) - -Connections -^^^^^^^^^^^ - -ConnectionPools manage a set of Connection instances. redis-py ships with two -types of Connections. The default, Connection, is a normal TCP socket based -connection. The UnixDomainSocketConnection allows for clients running on the -same device as the server to connect via a unix domain socket. To use a -UnixDomainSocketConnection connection, simply pass the unix_socket_path -argument, which is a string to the unix domain socket file. Additionally, make -sure the unixsocket parameter is defined in your redis.conf file. It's -commented out by default. - -.. code-block:: pycon - - >>> r = redis.Redis(unix_socket_path='/tmp/redis.sock') - -You can create your own Connection subclasses as well. This may be useful if -you want to control the socket behavior within an async framework. To -instantiate a client class using your own connection, you need to create -a connection pool, passing your class to the connection_class argument. -Other keyword parameters you pass to the pool will be passed to the class -specified during initialization. - -.. code-block:: pycon - - >>> pool = redis.ConnectionPool(connection_class=YourConnectionClass, - your_arg='...', ...) - -Connections maintain an open socket to the Redis server. Sometimes these -sockets are interrupted or disconnected for a variety of reasons. For example, -network appliances, load balancers and other services that sit between clients -and servers are often configured to kill connections that remain idle for a -given threshold. - -When a connection becomes disconnected, the next command issued on that -connection will fail and redis-py will raise a ConnectionError to the caller. -This allows each application that uses redis-py to handle errors in a way -that's fitting for that specific application. However, constant error -handling can be verbose and cumbersome, especially when socket disconnections -happen frequently in many production environments. - -To combat this, redis-py can issue regular health checks to assess the -liveliness of a connection just before issuing a command. Users can pass -``health_check_interval=N`` to the Redis or ConnectionPool classes or -as a query argument within a Redis URL. The value of ``health_check_interval`` -must be an integer. A value of ``0``, the default, disables health checks. -Any positive integer will enable health checks. Health checks are performed -just before a command is executed if the underlying connection has been idle -for more than ``health_check_interval`` seconds. For example, -``health_check_interval=30`` will ensure that a health check is run on any -connection that has been idle for 30 or more seconds just before a command -is executed on that connection. - -If your application is running in an environment that disconnects idle -connections after 30 seconds you should set the ``health_check_interval`` -option to a value less than 30. - -This option also works on any PubSub connection that is created from a -client with ``health_check_interval`` enabled. PubSub users need to ensure -that ``get_message()`` or ``listen()`` are called more frequently than -``health_check_interval`` seconds. It is assumed that most workloads already -do this. - -If your PubSub use case doesn't call ``get_message()`` or ``listen()`` -frequently, you should call ``pubsub.check_health()`` explicitly on a -regularly basis. - -Parsers -^^^^^^^ - -Parser classes provide a way to control how responses from the Redis server -are parsed. redis-py ships with two parser classes, the PythonParser and the -HiredisParser. By default, redis-py will attempt to use the HiredisParser if -you have the hiredis module installed and will fallback to the PythonParser -otherwise. - -Hiredis is a C library maintained by the core Redis team. Pieter Noordhuis was -kind enough to create Python bindings. Using Hiredis can provide up to a -10x speed improvement in parsing responses from the Redis server. The -performance increase is most noticeable when retrieving many pieces of data, -such as from LRANGE or SMEMBERS operations. - -Hiredis is available on PyPI, and can be installed via pip just like redis-py. - -.. code-block:: bash - - $ pip install hiredis - -Response Callbacks -^^^^^^^^^^^^^^^^^^ - -The client class uses a set of callbacks to cast Redis responses to the -appropriate Python type. There are a number of these callbacks defined on -the Redis client class in a dictionary called RESPONSE_CALLBACKS. - -Custom callbacks can be added on a per-instance basis using the -set_response_callback method. This method accepts two arguments: a command -name and the callback. Callbacks added in this manner are only valid on the -instance the callback is added to. If you want to define or override a callback -globally, you should make a subclass of the Redis client and add your callback -to its RESPONSE_CALLBACKS class dictionary. - -Response callbacks take at least one parameter: the response from the Redis -server. Keyword arguments may also be accepted in order to further control -how to interpret the response. These keyword arguments are specified during the -command's call to execute_command. The ZRANGE implementation demonstrates the -use of response callback keyword arguments with its "withscores" argument. - -Thread Safety -^^^^^^^^^^^^^ - -Redis client instances can safely be shared between threads. Internally, -connection instances are only retrieved from the connection pool during -command execution, and returned to the pool directly after. Command execution -never modifies state on the client instance. - -However, there is one caveat: the Redis SELECT command. The SELECT command -allows you to switch the database currently in use by the connection. That -database remains selected until another is selected or until the connection is -closed. This creates an issue in that connections could be returned to the pool -that are connected to a different database. - -As a result, redis-py does not implement the SELECT command on client -instances. If you use multiple Redis databases within the same application, you -should create a separate client instance (and possibly a separate connection -pool) for each database. - -It is not safe to pass PubSub or Pipeline objects between threads. - -Pipelines -^^^^^^^^^ - -Pipelines are a subclass of the base Redis class that provide support for -buffering multiple commands to the server in a single request. They can be used -to dramatically increase the performance of groups of commands by reducing the -number of back-and-forth TCP packets between the client and server. - -Pipelines are quite simple to use: - -.. code-block:: pycon - - >>> r = redis.Redis(...) - >>> r.set('bing', 'baz') - >>> # Use the pipeline() method to create a pipeline instance - >>> pipe = r.pipeline() - >>> # The following SET commands are buffered - >>> pipe.set('foo', 'bar') - >>> pipe.get('bing') - >>> # the EXECUTE call sends all buffered commands to the server, returning - >>> # a list of responses, one for each command. - >>> pipe.execute() - [True, b'baz'] - -For ease of use, all commands being buffered into the pipeline return the -pipeline object itself. Therefore calls can be chained like: - -.. code-block:: pycon - - >>> pipe.set('foo', 'bar').sadd('faz', 'baz').incr('auto_number').execute() - [True, True, 6] - -In addition, pipelines can also ensure the buffered commands are executed -atomically as a group. This happens by default. If you want to disable the -atomic nature of a pipeline but still want to buffer commands, you can turn -off transactions. - -.. code-block:: pycon - - >>> pipe = r.pipeline(transaction=False) - -A common issue occurs when requiring atomic transactions but needing to -retrieve values in Redis prior for use within the transaction. For instance, -let's assume that the INCR command didn't exist and we need to build an atomic -version of INCR in Python. - -The completely naive implementation could GET the value, increment it in -Python, and SET the new value back. However, this is not atomic because -multiple clients could be doing this at the same time, each getting the same -value from GET. - -Enter the WATCH command. WATCH provides the ability to monitor one or more keys -prior to starting a transaction. If any of those keys change prior the -execution of that transaction, the entire transaction will be canceled and a -WatchError will be raised. To implement our own client-side INCR command, we -could do something like this: - -.. code-block:: pycon - - >>> with r.pipeline() as pipe: - ... while True: - ... try: - ... # put a WATCH on the key that holds our sequence value - ... pipe.watch('OUR-SEQUENCE-KEY') - ... # after WATCHing, the pipeline is put into immediate execution - ... # mode until we tell it to start buffering commands again. - ... # this allows us to get the current value of our sequence - ... current_value = pipe.get('OUR-SEQUENCE-KEY') - ... next_value = int(current_value) + 1 - ... # now we can put the pipeline back into buffered mode with MULTI - ... pipe.multi() - ... pipe.set('OUR-SEQUENCE-KEY', next_value) - ... # and finally, execute the pipeline (the set command) - ... pipe.execute() - ... # if a WatchError wasn't raised during execution, everything - ... # we just did happened atomically. - ... break - ... except WatchError: - ... # another client must have changed 'OUR-SEQUENCE-KEY' between - ... # the time we started WATCHing it and the pipeline's execution. - ... # our best bet is to just retry. - ... continue - -Note that, because the Pipeline must bind to a single connection for the -duration of a WATCH, care must be taken to ensure that the connection is -returned to the connection pool by calling the reset() method. If the -Pipeline is used as a context manager (as in the example above) reset() -will be called automatically. Of course you can do this the manual way by -explicitly calling reset(): - -.. code-block:: pycon - - >>> pipe = r.pipeline() - >>> while True: - ... try: - ... pipe.watch('OUR-SEQUENCE-KEY') - ... ... - ... pipe.execute() - ... break - ... except WatchError: - ... continue - ... finally: - ... pipe.reset() - -A convenience method named "transaction" exists for handling all the -boilerplate of handling and retrying watch errors. It takes a callable that -should expect a single parameter, a pipeline object, and any number of keys to -be WATCHed. Our client-side INCR command above can be written like this, -which is much easier to read: - -.. code-block:: pycon - - >>> def client_side_incr(pipe): - ... current_value = pipe.get('OUR-SEQUENCE-KEY') - ... next_value = int(current_value) + 1 - ... pipe.multi() - ... pipe.set('OUR-SEQUENCE-KEY', next_value) - >>> - >>> r.transaction(client_side_incr, 'OUR-SEQUENCE-KEY') - [True] - -Be sure to call `pipe.multi()` in the callable passed to `Redis.transaction` -prior to any write commands. - -Publish / Subscribe -^^^^^^^^^^^^^^^^^^^ - -redis-py includes a `PubSub` object that subscribes to channels and listens -for new messages. Creating a `PubSub` object is easy. - -.. code-block:: pycon - - >>> r = redis.Redis(...) - >>> p = r.pubsub() - -Once a `PubSub` instance is created, channels and patterns can be subscribed -to. - -.. code-block:: pycon - - >>> p.subscribe('my-first-channel', 'my-second-channel', ...) - >>> p.psubscribe('my-*', ...) - -The `PubSub` instance is now subscribed to those channels/patterns. The -subscription confirmations can be seen by reading messages from the `PubSub` -instance. - -.. code-block:: pycon - - >>> p.get_message() - {'pattern': None, 'type': 'subscribe', 'channel': b'my-second-channel', 'data': 1} - >>> p.get_message() - {'pattern': None, 'type': 'subscribe', 'channel': b'my-first-channel', 'data': 2} - >>> p.get_message() - {'pattern': None, 'type': 'psubscribe', 'channel': b'my-*', 'data': 3} - -Every message read from a `PubSub` instance will be a dictionary with the -following keys. - -* **type**: One of the following: 'subscribe', 'unsubscribe', 'psubscribe', - 'punsubscribe', 'message', 'pmessage' -* **channel**: The channel [un]subscribed to or the channel a message was - published to -* **pattern**: The pattern that matched a published message's channel. Will be - `None` in all cases except for 'pmessage' types. -* **data**: The message data. With [un]subscribe messages, this value will be - the number of channels and patterns the connection is currently subscribed - to. With [p]message messages, this value will be the actual published - message. - -Let's send a message now. - -.. code-block:: pycon - - # the publish method returns the number matching channel and pattern - # subscriptions. 'my-first-channel' matches both the 'my-first-channel' - # subscription and the 'my-*' pattern subscription, so this message will - # be delivered to 2 channels/patterns - >>> r.publish('my-first-channel', 'some data') - 2 - >>> p.get_message() - {'channel': b'my-first-channel', 'data': b'some data', 'pattern': None, 'type': 'message'} - >>> p.get_message() - {'channel': b'my-first-channel', 'data': b'some data', 'pattern': b'my-*', 'type': 'pmessage'} - -Unsubscribing works just like subscribing. If no arguments are passed to -[p]unsubscribe, all channels or patterns will be unsubscribed from. - -.. code-block:: pycon - - >>> p.unsubscribe() - >>> p.punsubscribe('my-*') - >>> p.get_message() - {'channel': b'my-second-channel', 'data': 2, 'pattern': None, 'type': 'unsubscribe'} - >>> p.get_message() - {'channel': b'my-first-channel', 'data': 1, 'pattern': None, 'type': 'unsubscribe'} - >>> p.get_message() - {'channel': b'my-*', 'data': 0, 'pattern': None, 'type': 'punsubscribe'} - -redis-py also allows you to register callback functions to handle published -messages. Message handlers take a single argument, the message, which is a -dictionary just like the examples above. To subscribe to a channel or pattern -with a message handler, pass the channel or pattern name as a keyword argument -with its value being the callback function. - -When a message is read on a channel or pattern with a message handler, the -message dictionary is created and passed to the message handler. In this case, -a `None` value is returned from get_message() since the message was already -handled. - -.. code-block:: pycon - - >>> def my_handler(message): - ... print('MY HANDLER: ', message['data']) - >>> p.subscribe(**{'my-channel': my_handler}) - # read the subscribe confirmation message - >>> p.get_message() - {'pattern': None, 'type': 'subscribe', 'channel': b'my-channel', 'data': 1} - >>> r.publish('my-channel', 'awesome data') - 1 - # for the message handler to work, we need tell the instance to read data. - # this can be done in several ways (read more below). we'll just use - # the familiar get_message() function for now - >>> message = p.get_message() - MY HANDLER: awesome data - # note here that the my_handler callback printed the string above. - # `message` is None because the message was handled by our handler. - >>> print(message) - None - -If your application is not interested in the (sometimes noisy) -subscribe/unsubscribe confirmation messages, you can ignore them by passing -`ignore_subscribe_messages=True` to `r.pubsub()`. This will cause all -subscribe/unsubscribe messages to be read, but they won't bubble up to your -application. - -.. code-block:: pycon - - >>> p = r.pubsub(ignore_subscribe_messages=True) - >>> p.subscribe('my-channel') - >>> p.get_message() # hides the subscribe message and returns None - >>> r.publish('my-channel', 'my data') - 1 - >>> p.get_message() - {'channel': b'my-channel', 'data': b'my data', 'pattern': None, 'type': 'message'} - -There are three different strategies for reading messages. - -The examples above have been using `pubsub.get_message()`. Behind the scenes, -`get_message()` uses the system's 'select' module to quickly poll the -connection's socket. If there's data available to be read, `get_message()` will -read it, format the message and return it or pass it to a message handler. If -there's no data to be read, `get_message()` will immediately return None. This -makes it trivial to integrate into an existing event loop inside your -application. - -.. code-block:: pycon - - >>> while True: - >>> message = p.get_message() - >>> if message: - >>> # do something with the message - >>> time.sleep(0.001) # be nice to the system :) - -Older versions of redis-py only read messages with `pubsub.listen()`. listen() -is a generator that blocks until a message is available. If your application -doesn't need to do anything else but receive and act on messages received from -redis, listen() is an easy way to get up an running. - -.. code-block:: pycon - - >>> for message in p.listen(): - ... # do something with the message - -The third option runs an event loop in a separate thread. -`pubsub.run_in_thread()` creates a new thread and starts the event loop. The -thread object is returned to the caller of `run_in_thread()`. The caller can -use the `thread.stop()` method to shut down the event loop and thread. Behind -the scenes, this is simply a wrapper around `get_message()` that runs in a -separate thread, essentially creating a tiny non-blocking event loop for you. -`run_in_thread()` takes an optional `sleep_time` argument. If specified, the -event loop will call `time.sleep()` with the value in each iteration of the -loop. - -Note: Since we're running in a separate thread, there's no way to handle -messages that aren't automatically handled with registered message handlers. -Therefore, redis-py prevents you from calling `run_in_thread()` if you're -subscribed to patterns or channels that don't have message handlers attached. - -.. code-block:: pycon - - >>> p.subscribe(**{'my-channel': my_handler}) - >>> thread = p.run_in_thread(sleep_time=0.001) - # the event loop is now running in the background processing messages - # when it's time to shut it down... - >>> thread.stop() - -`run_in_thread` also supports an optional exception handler, which lets you -catch exceptions that occur within the worker thread and handle them -appropriately. The exception handler will take as arguments the exception -itself, the pubsub object, and the worker thread returned by `run_in_thread`. - -.. code-block:: pycon - - >>> p.subscribe(**{'my-channel': my_handler}) - >>> def exception_handler(ex, pubsub, thread): - >>> print(ex) - >>> thread.stop() - >>> thread.join(timeout=1.0) - >>> pubsub.close() - >>> thread = p.run_in_thread(exception_handler=exception_handler) - -A PubSub object adheres to the same encoding semantics as the client instance -it was created from. Any channel or pattern that's unicode will be encoded -using the `charset` specified on the client before being sent to Redis. If the -client's `decode_responses` flag is set the False (the default), the -'channel', 'pattern' and 'data' values in message dictionaries will be byte -strings (str on Python 2, bytes on Python 3). If the client's -`decode_responses` is True, then the 'channel', 'pattern' and 'data' values -will be automatically decoded to unicode strings using the client's `charset`. - -PubSub objects remember what channels and patterns they are subscribed to. In -the event of a disconnection such as a network error or timeout, the -PubSub object will re-subscribe to all prior channels and patterns when -reconnecting. Messages that were published while the client was disconnected -cannot be delivered. When you're finished with a PubSub object, call its -`.close()` method to shutdown the connection. - -.. code-block:: pycon - - >>> p = r.pubsub() - >>> ... - >>> p.close() - - -The PUBSUB set of subcommands CHANNELS, NUMSUB and NUMPAT are also -supported: - -.. code-block:: pycon - - >>> r.pubsub_channels() - [b'foo', b'bar'] - >>> r.pubsub_numsub('foo', 'bar') - [(b'foo', 9001), (b'bar', 42)] - >>> r.pubsub_numsub('baz') - [(b'baz', 0)] - >>> r.pubsub_numpat() - 1204 - -Monitor -^^^^^^^ -redis-py includes a `Monitor` object that streams every command processed -by the Redis server. Use `listen()` on the `Monitor` object to block -until a command is received. - -.. code-block:: pycon - - >>> r = redis.Redis(...) - >>> with r.monitor() as m: - >>> for command in m.listen(): - >>> print(command) - -Lua Scripting -^^^^^^^^^^^^^ - -redis-py supports the EVAL, EVALSHA, and SCRIPT commands. However, there are -a number of edge cases that make these commands tedious to use in real world -scenarios. Therefore, redis-py exposes a Script object that makes scripting -much easier to use. - -To create a Script instance, use the `register_script` function on a client -instance passing the Lua code as the first argument. `register_script` returns -a Script instance that you can use throughout your code. - -The following trivial Lua script accepts two parameters: the name of a key and -a multiplier value. The script fetches the value stored in the key, multiplies -it with the multiplier value and returns the result. - -.. code-block:: pycon - - >>> r = redis.Redis() - >>> lua = """ - ... local value = redis.call('GET', KEYS[1]) - ... value = tonumber(value) - ... return value * ARGV[1]""" - >>> multiply = r.register_script(lua) - -`multiply` is now a Script instance that is invoked by calling it like a -function. Script instances accept the following optional arguments: - -* **keys**: A list of key names that the script will access. This becomes the - KEYS list in Lua. -* **args**: A list of argument values. This becomes the ARGV list in Lua. -* **client**: A redis-py Client or Pipeline instance that will invoke the - script. If client isn't specified, the client that initially - created the Script instance (the one that `register_script` was - invoked from) will be used. - -Continuing the example from above: - -.. code-block:: pycon - - >>> r.set('foo', 2) - >>> multiply(keys=['foo'], args=[5]) - 10 - -The value of key 'foo' is set to 2. When multiply is invoked, the 'foo' key is -passed to the script along with the multiplier value of 5. Lua executes the -script and returns the result, 10. - -Script instances can be executed using a different client instance, even one -that points to a completely different Redis server. - -.. code-block:: pycon - - >>> r2 = redis.Redis('redis2.example.com') - >>> r2.set('foo', 3) - >>> multiply(keys=['foo'], args=[5], client=r2) - 15 - -The Script object ensures that the Lua script is loaded into Redis's script -cache. In the event of a NOSCRIPT error, it will load the script and retry -executing it. - -Script objects can also be used in pipelines. The pipeline instance should be -passed as the client argument when calling the script. Care is taken to ensure -that the script is registered in Redis's script cache just prior to pipeline -execution. - -.. code-block:: pycon - - >>> pipe = r.pipeline() - >>> pipe.set('foo', 5) - >>> multiply(keys=['foo'], args=[5], client=pipe) - >>> pipe.execute() - [True, 25] - -Sentinel support -^^^^^^^^^^^^^^^^ - -redis-py can be used together with `Redis Sentinel `_ -to discover Redis nodes. You need to have at least one Sentinel daemon running -in order to use redis-py's Sentinel support. - -Connecting redis-py to the Sentinel instance(s) is easy. You can use a -Sentinel connection to discover the master and slaves network addresses: - -.. code-block:: pycon - - >>> from redis.sentinel import Sentinel - >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) - >>> sentinel.discover_master('mymaster') - ('127.0.0.1', 6379) - >>> sentinel.discover_slaves('mymaster') - [('127.0.0.1', 6380)] - -You can also create Redis client connections from a Sentinel instance. You can -connect to either the master (for write operations) or a slave (for read-only -operations). - -.. code-block:: pycon - - >>> master = sentinel.master_for('mymaster', socket_timeout=0.1) - >>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1) - >>> master.set('foo', 'bar') - >>> slave.get('foo') - b'bar' - -The master and slave objects are normal Redis instances with their -connection pool bound to the Sentinel instance. When a Sentinel backed client -attempts to establish a connection, it first queries the Sentinel servers to -determine an appropriate host to connect to. If no server is found, -a MasterNotFoundError or SlaveNotFoundError is raised. Both exceptions are -subclasses of ConnectionError. - -When trying to connect to a slave client, the Sentinel connection pool will -iterate over the list of slaves until it finds one that can be connected to. -If no slaves can be connected to, a connection will be established with the -master. - -See `Guidelines for Redis clients with support for Redis Sentinel -`_ to learn more about Redis Sentinel. - -Scan Iterators -^^^^^^^^^^^^^^ - -The \*SCAN commands introduced in Redis 2.8 can be cumbersome to use. While -these commands are fully supported, redis-py also exposes the following methods -that return Python iterators for convenience: `scan_iter`, `hscan_iter`, -`sscan_iter` and `zscan_iter`. - -.. code-block:: pycon - - >>> for key, value in (('A', '1'), ('B', '2'), ('C', '3')): - ... r.set(key, value) - >>> for key in r.scan_iter(): - ... print(key, r.get(key)) - A 1 - B 2 - C 3 - -Cluster Mode -^^^^^^^^^^^^ - -redis-py does not currently support `Cluster Mode -`_. - -Author -^^^^^^ - -redis-py is developed and maintained by Andy McCurdy (sedrik@gmail.com). -It can be found here: https://github.com/redis/redis-py - -Special thanks to: - -* Ludovico Magnocavallo, author of the original Python Redis client, from - which some of the socket code is still used. -* Alexander Solovyov for ideas on the generic response callback system. -* Paul Hubbard for initial packaging support. - - -Sponsored by -^^^^^^^^^^^^ - -.. image:: ./docs/logo-redis.png - :alt: Redis - :target: https://www.redis.com From 20f71abcbf9af4d628c2e1ae1ad07457f8f31bef Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 26 Oct 2021 10:19:00 +0300 Subject: [PATCH 0212/1164] beta2 version and CHANGES update (#1643) --- CHANGES | 7 +++++++ redis/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 801e2f1253..b4372be34e 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,10 @@ +# DEPRECATED + +This file is historic. Starting with redis-py 4.0.0b1, please see the GitHub releases page at +https://github.com/redis/redis-py/releases. + +------------------------------------------------------------------------------------------------ + * (in development) * BACKWARDS INCOMPATIBLE: Removed support for end of life Python 2.7. #1318 * BACKWARDS INCOMPATIBLE: All values within Redis URLs are unquoted via diff --git a/redis/__init__.py b/redis/__init__.py index a3b5eb67e3..3e7831e87b 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -31,7 +31,7 @@ def int_or_str(value): return value -__version__ = '4.0.0b1' +__version__ = '4.0.0b2' VERSION = tuple(map(int_or_str, __version__.split('.'))) __all__ = [ From 2b0a1e72b82b1706ae8f9939dab0ddd62efe413f Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 26 Oct 2021 12:25:59 +0300 Subject: [PATCH 0213/1164] re-enabling codecov as part of CI process (#1646) --- .github/workflows/integration.yaml | 10 +++++----- README.md | 3 ++- dev_requirements.txt | 1 + tox.ini | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 2618c336da..45254d0ad0 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -6,11 +6,6 @@ on: - 'docs/**' - '**/*.rst' - '**/*.md' - pull_request: - paths-ignore: - - 'docs/**' - - '**/*.rst' - - '**/*.md' jobs: @@ -47,6 +42,11 @@ jobs: run: | pip install -r dev_requirements.txt invoke tests + - name: Upload codecov coverage + uses: codecov/codecov-action@v2 + with: + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} build_package: name: Validate building and installing the package diff --git a/README.md b/README.md index 1b535ae36f..b6d3115a0b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ The Python interface to the Redis key-value store. [![image](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster) [![image](https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) [![image](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/) -[![image](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg)](https://codecov.io/gh/redis/redis-py) +[![codecov](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg?token=yenl5fzxxr)](https://codecov.io/gh/redis/redis-py) + ## Python 2 Compatibility Note diff --git a/dev_requirements.txt b/dev_requirements.txt index 2648127712..d3f91fef3d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -3,3 +3,4 @@ pytest==6.2.5 tox==3.24.4 tox-docker==3.1.0 invoke==1.6.0 +pytest-cov>=3.0.0 \ No newline at end of file diff --git a/tox.ini b/tox.ini index 7d79d38f13..67b7e7575d 100644 --- a/tox.ini +++ b/tox.ini @@ -86,7 +86,7 @@ docker = extras = hiredis: hiredis commands = - pytest -W always {posargs} + pytest --cov=./ --cov-report=xml -W always {posargs} [testenv:devenv] skipsdist = true From 866ac00b45a144753c40bb466e784a8917212172 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 26 Oct 2021 14:13:20 +0300 Subject: [PATCH 0214/1164] Fixing the package to include commands (#1649) * Fixing the package to include commands. Fixes #1645 --- docs/conf.py | 152 +++++++++++++++++++++++++--------------------- redis/__init__.py | 2 +- setup.cfg | 39 ------------ setup.py | 43 ++++++++++++- 4 files changed, 124 insertions(+), 112 deletions(-) delete mode 100644 setup.cfg diff --git a/docs/conf.py b/docs/conf.py index 3eb3f33ef2..dfdaf9ea57 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,212 +16,218 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) sys.path.append(os.path.abspath(os.path.pardir)) # -- General configuration ---------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.viewcode'] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.viewcode" +] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'redis-py' -copyright = '2016, Andy McCurdy' +project = "redis-py" +copyright = "2016, Andy McCurdy" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '2.10.5' +version = "2.10.5" # The full version, including alpha/beta/rc tags. -release = '2.10.5' +release = "2.10.5" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'redis-pydoc' +htmlhelp_basename = "redis-pydoc" # -- Options for LaTeX output ------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', - + # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', - + # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. - #'preamble': '', + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass # [howto/manual]). latex_documents = [ - ('index', 'redis-py.tex', 'redis-py Documentation', - 'Andy McCurdy', 'manual'), + ("index", + "redis-py.tex", + "redis-py Documentation", + "Andy McCurdy", + "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'redis-py', 'redis-py Documentation', - ['Andy McCurdy'], 1) -] +man_pages = [( + "index", + "redis-py", + "redis-py Documentation", + ["Andy McCurdy"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ----------------------------------------------- @@ -230,21 +236,27 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'redis-py', 'redis-py Documentation', - 'Andy McCurdy', 'redis-py', - 'One line description of project.', 'Miscellaneous'), + ( + "index", + "redis-py", + "redis-py Documentation", + "Andy McCurdy", + "redis-py", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' -epub_title = 'redis-py' -epub_author = 'Andy McCurdy' -epub_publisher = 'Andy McCurdy' -epub_copyright = '2011, Andy McCurdy' +epub_title = "redis-py" +epub_author = "Andy McCurdy" +epub_publisher = "Andy McCurdy" +epub_copyright = "2011, Andy McCurdy" diff --git a/redis/__init__.py b/redis/__init__.py index 3e7831e87b..2458b5bc49 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -31,7 +31,7 @@ def int_or_str(value): return value -__version__ = '4.0.0b2' +__version__ = '4.0.0b3' VERSION = tuple(map(int_or_str, __version__.split('.'))) __all__ = [ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a9c98491f8..0000000000 --- a/setup.cfg +++ /dev/null @@ -1,39 +0,0 @@ -[metadata] -name = redis -version = attr: redis.__version__ -description = Python client for Redis key-value store -long_description = file: README.rst -url = https://github.com/andymccurdy/redis-py -author = Andy McCurdy -author_email = sedrik@gmail.com -maintainer = Andy McCurdy -maintainer_email = sedrik@gmail.com -keywords = Redis, key-value store -license = MIT -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Console - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: Implementation :: CPython - Programming Language :: Python :: Implementation :: PyPy - - -[options] -packages = redis -python_requires = >=3.5 - -[options.extras_require] -hiredis = hiredis>=0.1.3 - -[flake8] -exclude = .venv,.tox,dist,docs,build,*.egg,redis_install,env,venv,.undodir diff --git a/setup.py b/setup.py index c823345536..50c3d912ee 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,43 @@ #!/usr/bin/env python -from setuptools import setup +from setuptools import setup, find_packages +import redis -setup() +setup( + name="redis", + description="Python client for Redis database and key-value store", + long_description=open("README.md").read().strip(), + keywords=["Redis", "key-value store", "database"], + license="MIT", + version=redis.__version__, + packages=find_packages( + include=[ + "redis", + "redis.commands", + "redis.commands.json", + "redis.commands.search", + ] + ), + url="https://github.com/redis/redis-py", + author="Redis Inc.", + author_email="oss@redis.com", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + ], + extras_require={ + "hiredis": ["hiredis>=1.0.0"], + }, +) From 396e7323959b7a79d72602afee3ef648dc23adf9 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 26 Oct 2021 14:24:54 +0300 Subject: [PATCH 0215/1164] Exposing the module version in loaded_modules (#1648) This is useful for the case where one wants to instantiate a module, knowing the back end version. The reason: behaviour may differ based on redis module versions. --- redis/client.py | 3 ++- redis/commands/json/__init__.py | 2 ++ redis/commands/redismodules.py | 18 +++++++++++++----- redis/commands/search/__init__.py | 3 ++- tests/test_connection.py | 2 +- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/redis/client.py b/redis/client.py index 4db9887f4d..986af7cfba 100755 --- a/redis/client.py +++ b/redis/client.py @@ -930,7 +930,8 @@ def loaded_modules(self): return mods try: - mods = [f.get('name').lower() for f in self.info().get('modules')] + mods = {f.get('name').lower(): f.get('ver') + for f in self.info().get('modules')} except TypeError: mods = [] setattr(self, key, mods) diff --git a/redis/commands/json/__init__.py b/redis/commands/json/__init__.py index 92f119992f..2e26de3efe 100644 --- a/redis/commands/json/__init__.py +++ b/redis/commands/json/__init__.py @@ -23,6 +23,7 @@ class JSON(JSONCommands): def __init__( self, client, + version=None, decoder=JSONDecoder(), encoder=JSONEncoder(), ): @@ -62,6 +63,7 @@ def __init__( self.client = client self.execute_command = client.execute_command + self.MODULE_VERSION = version for key, value in self.MODULE_CALLBACKS.items(): self.client.set_response_callback(key, value) diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py index 2c9066a2e7..3ecce295c2 100644 --- a/redis/commands/redismodules.py +++ b/redis/commands/redismodules.py @@ -9,18 +9,26 @@ class RedisModuleCommands: def json(self, encoder=JSONEncoder(), decoder=JSONDecoder()): """Access the json namespace, providing support for redis json.""" - if 'rejson' not in self.loaded_modules: + try: + modversion = self.loaded_modules['rejson'] + except IndexError: raise ModuleError("rejson is not a loaded in the redis instance.") from .json import JSON - jj = JSON(client=self, encoder=encoder, decoder=decoder) + jj = JSON( + client=self, + version=modversion, + encoder=encoder, + decoder=decoder) return jj def ft(self, index_name="idx"): """Access the search namespace, providing support for redis search.""" - if 'search' not in self.loaded_modules: - raise ModuleError("search is not a loaded in the redis instance.") + try: + modversion = self.loaded_modules['search'] + except IndexError: + raise ModuleError("rejson is not a loaded in the redis instance.") from .search import Search - s = Search(client=self, index_name=index_name) + s = Search(client=self, version=modversion, index_name=index_name) return s diff --git a/redis/commands/search/__init__.py b/redis/commands/search/__init__.py index 8320ad4392..425578eabd 100644 --- a/redis/commands/search/__init__.py +++ b/redis/commands/search/__init__.py @@ -83,7 +83,7 @@ def commit(self): self.pipeline.execute() self.current_chunk = 0 - def __init__(self, client, index_name="idx"): + def __init__(self, client, version=None, index_name="idx"): """ Create a new Client for the given index_name. The default name is `idx` @@ -91,6 +91,7 @@ def __init__(self, client, index_name="idx"): If conn is not None, we employ an already existing redis connection """ self.client = client + self.MODULE_VERSION = version self.index_name = index_name self.execute_command = client.execute_command self.pipeline = client.pipeline diff --git a/tests/test_connection.py b/tests/test_connection.py index 6728e0a05f..fa9a2b0c90 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -20,7 +20,7 @@ def test_invalid_response(r): @skip_if_server_version_lt('4.0.0') def test_loaded_modules(r, modclient): assert r.loaded_modules == [] - assert 'rejson' in modclient.loaded_modules + assert 'rejson' in modclient.loaded_modules.keys() @skip_if_server_version_lt('4.0.0') From 1f3d9700a9fbe9143ca7c344b5425e08f9220105 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 26 Oct 2021 14:33:41 +0300 Subject: [PATCH 0216/1164] Adding description format for package (#1651) --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 50c3d912ee..9555f7bda6 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,7 @@ name="redis", description="Python client for Redis database and key-value store", long_description=open("README.md").read().strip(), + long_description_content_type="text/markdown", keywords=["Redis", "key-value store", "database"], license="MIT", version=redis.__version__, From 20177868a94e5ff190551d4cdf2e7acc7126733f Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 26 Oct 2021 17:22:45 +0300 Subject: [PATCH 0217/1164] restore actions to prs (#1653) * restore actions to prs * limiting pr runs for actions against master --- .github/workflows/integration.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 45254d0ad0..4a073948cb 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -6,6 +6,9 @@ on: - 'docs/**' - '**/*.rst' - '**/*.md' + pull_request: + branches: + - master jobs: From 992b149732a96ccfe049f84c1b556b8d14f12161 Mon Sep 17 00:00:00 2001 From: Nicusor Picatureanu <33037485+Nicusor97@users.noreply.github.com> Date: Wed, 27 Oct 2021 15:10:31 +0300 Subject: [PATCH 0218/1164] Add python_requires setuptools check for python > 3.6 (#1656) --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 9555f7bda6..9788d2ed96 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ url="https://github.com/redis/redis-py", author="Redis Inc.", author_email="oss@redis.com", + python_requires=">=3.6", classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Console", From 1f11f8c4ecbc2227c28077b9e9764e543c38d0a5 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Thu, 28 Oct 2021 09:36:52 +0300 Subject: [PATCH 0219/1164] Update badges in README.md (#1654) --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b6d3115a0b..4bcea7be33 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ The Python interface to the Redis key-value store. -[![image](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster) -[![image](https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) -[![image](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/) +[![CI](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster) +[![docs](https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) +[![pypi](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/) [![codecov](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg?token=yenl5fzxxr)](https://codecov.io/gh/redis/redis-py) +[![Total alerts](https://img.shields.io/lgtm/alerts/g/redis/redis-py.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/redis/redis-py/alerts/) ## Python 2 Compatibility Note From eaa56b7d721182541bab087a6d61304c778f7ea9 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 28 Oct 2021 09:57:03 +0300 Subject: [PATCH 0220/1164] redis timeseries support (#1652) --- redis/commands/helpers.py | 14 + redis/commands/json/__init__.py | 4 - redis/commands/redismodules.py | 16 +- redis/commands/timeseries/__init__.py | 61 ++ redis/commands/timeseries/commands.py | 775 ++++++++++++++++++++++++++ redis/commands/timeseries/info.py | 82 +++ redis/commands/timeseries/utils.py | 49 ++ setup.py | 1 + tests/test_timeseries.py | 593 ++++++++++++++++++++ 9 files changed, 1590 insertions(+), 5 deletions(-) create mode 100644 redis/commands/timeseries/__init__.py create mode 100644 redis/commands/timeseries/commands.py create mode 100644 redis/commands/timeseries/info.py create mode 100644 redis/commands/timeseries/utils.py create mode 100644 tests/test_timeseries.py diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index b012621b3b..a92c02503e 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -23,3 +23,17 @@ def nativestr(x): def delist(x): """Given a list of binaries, return the stringified version.""" return [nativestr(obj) for obj in x] + + +def parse_to_list(response): + """Optimistally parse the response to a list. + """ + res = [] + for item in response: + try: + res.append(int(item)) + except ValueError: + res.append(nativestr(item)) + except TypeError: + res.append(None) + return res diff --git a/redis/commands/json/__init__.py b/redis/commands/json/__init__.py index 2e26de3efe..978370553a 100644 --- a/redis/commands/json/__init__.py +++ b/redis/commands/json/__init__.py @@ -1,12 +1,8 @@ -# from typing import Optional from json import JSONDecoder, JSONEncoder -# # from redis.client import Redis - from .helpers import bulk_of_jsons from ..helpers import nativestr, delist from .commands import JSONCommands -# from ..feature import AbstractFeature class JSON(JSONCommands): diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py index 3ecce295c2..457a69e2a2 100644 --- a/redis/commands/redismodules.py +++ b/redis/commands/redismodules.py @@ -27,8 +27,22 @@ def ft(self, index_name="idx"): try: modversion = self.loaded_modules['search'] except IndexError: - raise ModuleError("rejson is not a loaded in the redis instance.") + raise ModuleError("search is not a loaded in the redis instance.") from .search import Search s = Search(client=self, version=modversion, index_name=index_name) return s + + def ts(self, index_name="idx"): + """Access the timeseries namespace, providing support for + redis timeseries data. + """ + try: + modversion = self.loaded_modules['timeseries'] + except IndexError: + raise ModuleError("timeseries is not a loaded in " + "the redis instance.") + + from .timeseries import TimeSeries + s = TimeSeries(client=self, version=modversion, index_name=index_name) + return s diff --git a/redis/commands/timeseries/__init__.py b/redis/commands/timeseries/__init__.py new file mode 100644 index 0000000000..db9c3a55d4 --- /dev/null +++ b/redis/commands/timeseries/__init__.py @@ -0,0 +1,61 @@ +from redis.client import bool_ok + +from .utils import ( + parse_range, + parse_get, + parse_m_range, + parse_m_get, +) +from .info import TSInfo +from ..helpers import parse_to_list +from .commands import ( + ALTER_CMD, + CREATE_CMD, + CREATERULE_CMD, + DELETERULE_CMD, + DEL_CMD, + GET_CMD, + INFO_CMD, + MGET_CMD, + MRANGE_CMD, + MREVRANGE_CMD, + QUERYINDEX_CMD, + RANGE_CMD, + REVRANGE_CMD, + TimeSeriesCommands, +) + + +class TimeSeries(TimeSeriesCommands): + """ + This class subclasses redis-py's `Redis` and implements RedisTimeSeries's + commands (prefixed with "ts"). + The client allows to interact with RedisTimeSeries and use all of it's + functionality. + """ + + def __init__(self, client=None, version=None, **kwargs): + """Create a new RedisTimeSeries client.""" + # Set the module commands' callbacks + MODULE_CALLBACKS = { + CREATE_CMD: bool_ok, + ALTER_CMD: bool_ok, + CREATERULE_CMD: bool_ok, + DEL_CMD: int, + DELETERULE_CMD: bool_ok, + RANGE_CMD: parse_range, + REVRANGE_CMD: parse_range, + MRANGE_CMD: parse_m_range, + MREVRANGE_CMD: parse_m_range, + GET_CMD: parse_get, + MGET_CMD: parse_m_get, + INFO_CMD: TSInfo, + QUERYINDEX_CMD: parse_to_list, + } + + self.client = client + self.execute_command = client.execute_command + self.MODULE_VERSION = version + + for k in MODULE_CALLBACKS: + self.client.set_response_callback(k, MODULE_CALLBACKS[k]) diff --git a/redis/commands/timeseries/commands.py b/redis/commands/timeseries/commands.py new file mode 100644 index 0000000000..3b9ee0f8b3 --- /dev/null +++ b/redis/commands/timeseries/commands.py @@ -0,0 +1,775 @@ +from redis.exceptions import DataError + + +ADD_CMD = "TS.ADD" +ALTER_CMD = "TS.ALTER" +CREATERULE_CMD = "TS.CREATERULE" +CREATE_CMD = "TS.CREATE" +DECRBY_CMD = "TS.DECRBY" +DELETERULE_CMD = "TS.DELETERULE" +DEL_CMD = "TS.DEL" +GET_CMD = "TS.GET" +INCRBY_CMD = "TS.INCRBY" +INFO_CMD = "TS.INFO" +MADD_CMD = "TS.MADD" +MGET_CMD = "TS.MGET" +MRANGE_CMD = "TS.MRANGE" +MREVRANGE_CMD = "TS.MREVRANGE" +QUERYINDEX_CMD = "TS.QUERYINDEX" +RANGE_CMD = "TS.RANGE" +REVRANGE_CMD = "TS.REVRANGE" + + +class TimeSeriesCommands: + """RedisTimeSeries Commands.""" + + def create(self, key, **kwargs): + """ + Create a new time-series. + For more information see + `TS.CREATE `_. # noqa + + Args: + + key: + time-series key + retention_msecs: + Maximum age for samples compared to last event time (in milliseconds). + If None or 0 is passed then the series is not trimmed at all. + uncompressed: + Since RedisTimeSeries v1.2, both timestamps and values are + compressed by default. + Adding this flag will keep data in an uncompressed form. + Compression not only saves + memory but usually improve performance due to lower number + of memory accesses. + labels: + Set of label-value pairs that represent metadata labels of the key. + chunk_size: + Each time-serie uses chunks of memory of fixed size for + time series samples. + You can alter the default TSDB chunk size by passing the + chunk_size argument (in Bytes). + duplicate_policy: + Since RedisTimeSeries v1.4 you can specify the duplicate sample policy + ( Configure what to do on duplicate sample. ) + Can be one of: + - 'block': an error will occur for any out of order sample. + - 'first': ignore the new value. + - 'last': override with latest value. + - 'min': only override if the value is lower than the existing value. + - 'max': only override if the value is higher than the existing value. + When this is not set, the server-wide default will be used. + """ + retention_msecs = kwargs.get("retention_msecs", None) + uncompressed = kwargs.get("uncompressed", False) + labels = kwargs.get("labels", {}) + chunk_size = kwargs.get("chunk_size", None) + duplicate_policy = kwargs.get("duplicate_policy", None) + params = [key] + self._appendRetention(params, retention_msecs) + self._appendUncompressed(params, uncompressed) + self._appendChunkSize(params, chunk_size) + self._appendDuplicatePolicy(params, CREATE_CMD, duplicate_policy) + self._appendLabels(params, labels) + + return self.execute_command(CREATE_CMD, *params) + + def alter(self, key, **kwargs): + """ + Update the retention, labels of an existing key. + For more information see + `TS.ALTER `_. # noqa + + The parameters are the same as TS.CREATE. + """ + retention_msecs = kwargs.get("retention_msecs", None) + labels = kwargs.get("labels", {}) + duplicate_policy = kwargs.get("duplicate_policy", None) + params = [key] + self._appendRetention(params, retention_msecs) + self._appendDuplicatePolicy(params, ALTER_CMD, duplicate_policy) + self._appendLabels(params, labels) + + return self.execute_command(ALTER_CMD, *params) + + def add(self, key, timestamp, value, **kwargs): + """ + Append (or create and append) a new sample to the series. + For more information see + `TS.ADD `_. # noqa + + Args: + + key: + time-series key + timestamp: + Timestamp of the sample. * can be used for automatic timestamp (using the system clock). + value: + Numeric data value of the sample + retention_msecs: + Maximum age for samples compared to last event time (in milliseconds). + If None or 0 is passed then the series is not trimmed at all. + uncompressed: + Since RedisTimeSeries v1.2, both timestamps and values are compressed by default. + Adding this flag will keep data in an uncompressed form. Compression not only saves + memory but usually improve performance due to lower number of memory accesses. + labels: + Set of label-value pairs that represent metadata labels of the key. + chunk_size: + Each time-serie uses chunks of memory of fixed size for time series samples. + You can alter the default TSDB chunk size by passing the chunk_size argument (in Bytes). + duplicate_policy: + Since RedisTimeSeries v1.4 you can specify the duplicate sample policy + (Configure what to do on duplicate sample). + Can be one of: + - 'block': an error will occur for any out of order sample. + - 'first': ignore the new value. + - 'last': override with latest value. + - 'min': only override if the value is lower than the existing value. + - 'max': only override if the value is higher than the existing value. + When this is not set, the server-wide default will be used. + """ + retention_msecs = kwargs.get("retention_msecs", None) + uncompressed = kwargs.get("uncompressed", False) + labels = kwargs.get("labels", {}) + chunk_size = kwargs.get("chunk_size", None) + duplicate_policy = kwargs.get("duplicate_policy", None) + params = [key, timestamp, value] + self._appendRetention(params, retention_msecs) + self._appendUncompressed(params, uncompressed) + self._appendChunkSize(params, chunk_size) + self._appendDuplicatePolicy(params, ADD_CMD, duplicate_policy) + self._appendLabels(params, labels) + + return self.execute_command(ADD_CMD, *params) + + def madd(self, ktv_tuples): + """ + Append (or create and append) a new `value` to series + `key` with `timestamp`. + Expects a list of `tuples` as (`key`,`timestamp`, `value`). + Return value is an array with timestamps of insertions. + For more information see + `TS.MADD `_. # noqa + """ + params = [] + for ktv in ktv_tuples: + for item in ktv: + params.append(item) + + return self.execute_command(MADD_CMD, *params) + + def incrby(self, key, value, **kwargs): + """ + Increment (or create an time-series and increment) the latest + sample's of a series. + This command can be used as a counter or gauge that automatically gets + history as a time series. + For more information see + `TS.INCRBY `_. # noqa + + Args: + + key: + time-series key + value: + Numeric data value of the sample + timestamp: + Timestamp of the sample. None can be used for automatic timestamp (using the system clock). + retention_msecs: + Maximum age for samples compared to last event time (in milliseconds). + If None or 0 is passed then the series is not trimmed at all. + uncompressed: + Since RedisTimeSeries v1.2, both timestamps and values are compressed by default. + Adding this flag will keep data in an uncompressed form. Compression not only saves + memory but usually improve performance due to lower number of memory accesses. + labels: + Set of label-value pairs that represent metadata labels of the key. + chunk_size: + Each time-series uses chunks of memory of fixed size for time series samples. + You can alter the default TSDB chunk size by passing the chunk_size argument (in Bytes). + """ + timestamp = kwargs.get("timestamp", None) + retention_msecs = kwargs.get("retention_msecs", None) + uncompressed = kwargs.get("uncompressed", False) + labels = kwargs.get("labels", {}) + chunk_size = kwargs.get("chunk_size", None) + params = [key, value] + self._appendTimestamp(params, timestamp) + self._appendRetention(params, retention_msecs) + self._appendUncompressed(params, uncompressed) + self._appendChunkSize(params, chunk_size) + self._appendLabels(params, labels) + + return self.execute_command(INCRBY_CMD, *params) + + def decrby(self, key, value, **kwargs): + """ + Decrement (or create an time-series and decrement) the + latest sample's of a series. + This command can be used as a counter or gauge that + automatically gets history as a time series. + For more information see + `TS.DECRBY `_. # noqa + + Args: + + key: + time-series key + value: + Numeric data value of the sample + timestamp: + Timestamp of the sample. None can be used for automatic + timestamp (using the system clock). + retention_msecs: + Maximum age for samples compared to last event time (in milliseconds). + If None or 0 is passed then the series is not trimmed at all. + uncompressed: + Since RedisTimeSeries v1.2, both timestamps and values are + compressed by default. + Adding this flag will keep data in an uncompressed form. + Compression not only saves + memory but usually improve performance due to lower number + of memory accesses. + labels: + Set of label-value pairs that represent metadata labels of the key. + chunk_size: + Each time-series uses chunks of memory of fixed size for time series samples. + You can alter the default TSDB chunk size by passing the chunk_size argument (in Bytes). + """ + timestamp = kwargs.get("timestamp", None) + retention_msecs = kwargs.get("retention_msecs", None) + uncompressed = kwargs.get("uncompressed", False) + labels = kwargs.get("labels", {}) + chunk_size = kwargs.get("chunk_size", None) + params = [key, value] + self._appendTimestamp(params, timestamp) + self._appendRetention(params, retention_msecs) + self._appendUncompressed(params, uncompressed) + self._appendChunkSize(params, chunk_size) + self._appendLabels(params, labels) + + return self.execute_command(DECRBY_CMD, *params) + + def delete(self, key, from_time, to_time): + """ + Delete data points for a given timeseries and interval range + in the form of start and end delete timestamps. + The given timestamp interval is closed (inclusive), meaning start + and end data points will also be deleted. + Return the count for deleted items. + For more information see + `TS.DEL `_. # noqa + + Args: + + key: + time-series key. + from_time: + Start timestamp for the range deletion. + to_time: + End timestamp for the range deletion. + """ + return self.execute_command(DEL_CMD, key, from_time, to_time) + + def createrule( + self, + source_key, + dest_key, + aggregation_type, + bucket_size_msec + ): + """ + Create a compaction rule from values added to `source_key` into `dest_key`. + Aggregating for `bucket_size_msec` where an `aggregation_type` can be + [`avg`, `sum`, `min`, `max`, `range`, `count`, `first`, `last`, + `std.p`, `std.s`, `var.p`, `var.s`] + For more information see + `TS.CREATERULE `_. # noqa + """ + params = [source_key, dest_key] + self._appendAggregation(params, aggregation_type, bucket_size_msec) + + return self.execute_command(CREATERULE_CMD, *params) + + def deleterule(self, source_key, dest_key): + """ + Delete a compaction rule. + For more information see + `TS.DELETERULE `_. # noqa + """ + return self.execute_command(DELETERULE_CMD, source_key, dest_key) + + def __range_params( + self, + key, + from_time, + to_time, + count, + aggregation_type, + bucket_size_msec, + filter_by_ts, + filter_by_min_value, + filter_by_max_value, + align, + ): + """Create TS.RANGE and TS.REVRANGE arguments.""" + params = [key, from_time, to_time] + self._appendFilerByTs(params, filter_by_ts) + self._appendFilerByValue( + params, + filter_by_min_value, + filter_by_max_value + ) + self._appendCount(params, count) + self._appendAlign(params, align) + self._appendAggregation(params, aggregation_type, bucket_size_msec) + + return params + + def range( + self, + key, + from_time, + to_time, + count=None, + aggregation_type=None, + bucket_size_msec=0, + filter_by_ts=None, + filter_by_min_value=None, + filter_by_max_value=None, + align=None, + ): + """ + Query a range in forward direction for a specific time-serie. + For more information see + `TS.RANGE `_. # noqa + + Args: + + key: + Key name for timeseries. + from_time: + Start timestamp for the range query. - can be used to express + the minimum possible timestamp (0). + to_time: + End timestamp for range query, + can be used to express the + maximum possible timestamp. + count: + Optional maximum number of returned results. + aggregation_type: + Optional aggregation type. Can be one of + [`avg`, `sum`, `min`, `max`, `range`, `count`, + `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`] + bucket_size_msec: + Time bucket for aggregation in milliseconds. + filter_by_ts: + List of timestamps to filter the result by specific timestamps. + filter_by_min_value: + Filter result by minimum value (must mention also filter + by_max_value). + filter_by_max_value: + Filter result by maximum value (must mention also filter + by_min_value). + align: + Timestamp for alignment control for aggregation. + """ + params = self.__range_params( + key, + from_time, + to_time, + count, + aggregation_type, + bucket_size_msec, + filter_by_ts, + filter_by_min_value, + filter_by_max_value, + align, + ) + return self.execute_command(RANGE_CMD, *params) + + def revrange( + self, + key, + from_time, + to_time, + count=None, + aggregation_type=None, + bucket_size_msec=0, + filter_by_ts=None, + filter_by_min_value=None, + filter_by_max_value=None, + align=None, + ): + """ + Query a range in reverse direction for a specific time-series. + For more information see + `TS.REVRANGE `_. # noqa + + **Note**: This command is only available since RedisTimeSeries >= v1.4 + + Args: + + key: + Key name for timeseries. + from_time: + Start timestamp for the range query. - can be used to express the minimum possible timestamp (0). + to_time: + End timestamp for range query, + can be used to express the maximum possible timestamp. + count: + Optional maximum number of returned results. + aggregation_type: + Optional aggregation type. Can be one of [`avg`, `sum`, `min`, `max`, `range`, `count`, + `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`] + bucket_size_msec: + Time bucket for aggregation in milliseconds. + filter_by_ts: + List of timestamps to filter the result by specific timestamps. + filter_by_min_value: + Filter result by minimum value (must mention also filter_by_max_value). + filter_by_max_value: + Filter result by maximum value (must mention also filter_by_min_value). + align: + Timestamp for alignment control for aggregation. + """ + params = self.__range_params( + key, + from_time, + to_time, + count, + aggregation_type, + bucket_size_msec, + filter_by_ts, + filter_by_min_value, + filter_by_max_value, + align, + ) + return self.execute_command(REVRANGE_CMD, *params) + + def __mrange_params( + self, + aggregation_type, + bucket_size_msec, + count, + filters, + from_time, + to_time, + with_labels, + filter_by_ts, + filter_by_min_value, + filter_by_max_value, + groupby, + reduce, + select_labels, + align, + ): + """Create TS.MRANGE and TS.MREVRANGE arguments.""" + params = [from_time, to_time] + self._appendFilerByTs(params, filter_by_ts) + self._appendFilerByValue( + params, + filter_by_min_value, + filter_by_max_value + ) + self._appendCount(params, count) + self._appendAlign(params, align) + self._appendAggregation(params, aggregation_type, bucket_size_msec) + self._appendWithLabels(params, with_labels, select_labels) + params.extend(["FILTER"]) + params += filters + self._appendGroupbyReduce(params, groupby, reduce) + return params + + def mrange( + self, + from_time, + to_time, + filters, + count=None, + aggregation_type=None, + bucket_size_msec=0, + with_labels=False, + filter_by_ts=None, + filter_by_min_value=None, + filter_by_max_value=None, + groupby=None, + reduce=None, + select_labels=None, + align=None, + ): + """ + Query a range across multiple time-series by filters in forward direction. + For more information see + `TS.MRANGE `_. # noqa + + Args: + + from_time: + Start timestamp for the range query. `-` can be used to + express the minimum possible timestamp (0). + to_time: + End timestamp for range query, `+` can be used to express + the maximum possible timestamp. + filters: + filter to match the time-series labels. + count: + Optional maximum number of returned results. + aggregation_type: + Optional aggregation type. Can be one of + [`avg`, `sum`, `min`, `max`, `range`, `count`, + `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`] + bucket_size_msec: + Time bucket for aggregation in milliseconds. + with_labels: + Include in the reply the label-value pairs that represent metadata + labels of the time-series. + If this argument is not set, by default, an empty Array will be + replied on the labels array position. + filter_by_ts: + List of timestamps to filter the result by specific timestamps. + filter_by_min_value: + Filter result by minimum value (must mention also + filter_by_max_value). + filter_by_max_value: + Filter result by maximum value (must mention also + filter_by_min_value). + groupby: + Grouping by fields the results (must mention also reduce). + reduce: + Applying reducer functions on each group. Can be one + of [`sum`, `min`, `max`]. + select_labels: + Include in the reply only a subset of the key-value + pair labels of a series. + align: + Timestamp for alignment control for aggregation. + """ + params = self.__mrange_params( + aggregation_type, + bucket_size_msec, + count, + filters, + from_time, + to_time, + with_labels, + filter_by_ts, + filter_by_min_value, + filter_by_max_value, + groupby, + reduce, + select_labels, + align, + ) + + return self.execute_command(MRANGE_CMD, *params) + + def mrevrange( + self, + from_time, + to_time, + filters, + count=None, + aggregation_type=None, + bucket_size_msec=0, + with_labels=False, + filter_by_ts=None, + filter_by_min_value=None, + filter_by_max_value=None, + groupby=None, + reduce=None, + select_labels=None, + align=None, + ): + """ + Query a range across multiple time-series by filters in reverse direction. + For more information see + `TS.MREVRANGE `_. # noqa + + Args: + + from_time: + Start timestamp for the range query. - can be used to express + the minimum possible timestamp (0). + to_time: + End timestamp for range query, + can be used to express + the maximum possible timestamp. + filters: + Filter to match the time-series labels. + count: + Optional maximum number of returned results. + aggregation_type: + Optional aggregation type. Can be one of + [`avg`, `sum`, `min`, `max`, `range`, `count`, + `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`] + bucket_size_msec: + Time bucket for aggregation in milliseconds. + with_labels: + Include in the reply the label-value pairs that represent + metadata labels + of the time-series. + If this argument is not set, by default, an empty Array + will be replied + on the labels array position. + filter_by_ts: + List of timestamps to filter the result by specific timestamps. + filter_by_min_value: + Filter result by minimum value (must mention also filter + by_max_value). + filter_by_max_value: + Filter result by maximum value (must mention also filter + by_min_value). + groupby: + Grouping by fields the results (must mention also reduce). + reduce: + Applying reducer functions on each group. Can be one + of [`sum`, `min`, `max`]. + select_labels: + Include in the reply only a subset of the key-value pair + labels of a series. + align: + Timestamp for alignment control for aggregation. + """ + params = self.__mrange_params( + aggregation_type, + bucket_size_msec, + count, + filters, + from_time, + to_time, + with_labels, + filter_by_ts, + filter_by_min_value, + filter_by_max_value, + groupby, + reduce, + select_labels, + align, + ) + + return self.execute_command(MREVRANGE_CMD, *params) + + def get(self, key): + """ # noqa + Get the last sample of `key`. + For more information see `TS.GET `_. + """ + return self.execute_command(GET_CMD, key) + + def mget(self, filters, with_labels=False): + """ # noqa + Get the last samples matching the specific `filter`. + For more information see `TS.MGET `_. + """ + params = [] + self._appendWithLabels(params, with_labels) + params.extend(["FILTER"]) + params += filters + return self.execute_command(MGET_CMD, *params) + + def info(self, key): + """ # noqa + Get information of `key`. + For more information see `TS.INFO `_. + """ + return self.execute_command(INFO_CMD, key) + + def queryindex(self, filters): + """ # noqa + Get all the keys matching the `filter` list. + For more information see `TS.QUERYINDEX `_. + """ + return self.execute_command(QUERYINDEX_CMD, *filters) + + @staticmethod + def _appendUncompressed(params, uncompressed): + """Append UNCOMPRESSED tag to params.""" + if uncompressed: + params.extend(["UNCOMPRESSED"]) + + @staticmethod + def _appendWithLabels(params, with_labels, select_labels=None): + """Append labels behavior to params.""" + if with_labels and select_labels: + raise DataError( + "with_labels and select_labels cannot be provided together." + ) + + if with_labels: + params.extend(["WITHLABELS"]) + if select_labels: + params.extend(["SELECTED_LABELS", *select_labels]) + + @staticmethod + def _appendGroupbyReduce(params, groupby, reduce): + """Append GROUPBY REDUCE property to params.""" + if groupby is not None and reduce is not None: + params.extend(["GROUPBY", groupby, "REDUCE", reduce.upper()]) + + @staticmethod + def _appendRetention(params, retention): + """Append RETENTION property to params.""" + if retention is not None: + params.extend(["RETENTION", retention]) + + @staticmethod + def _appendLabels(params, labels): + """Append LABELS property to params.""" + if labels: + params.append("LABELS") + for k, v in labels.items(): + params.extend([k, v]) + + @staticmethod + def _appendCount(params, count): + """Append COUNT property to params.""" + if count is not None: + params.extend(["COUNT", count]) + + @staticmethod + def _appendTimestamp(params, timestamp): + """Append TIMESTAMP property to params.""" + if timestamp is not None: + params.extend(["TIMESTAMP", timestamp]) + + @staticmethod + def _appendAlign(params, align): + """Append ALIGN property to params.""" + if align is not None: + params.extend(["ALIGN", align]) + + @staticmethod + def _appendAggregation(params, aggregation_type, bucket_size_msec): + """Append AGGREGATION property to params.""" + if aggregation_type is not None: + params.append("AGGREGATION") + params.extend([aggregation_type, bucket_size_msec]) + + @staticmethod + def _appendChunkSize(params, chunk_size): + """Append CHUNK_SIZE property to params.""" + if chunk_size is not None: + params.extend(["CHUNK_SIZE", chunk_size]) + + @staticmethod + def _appendDuplicatePolicy(params, command, duplicate_policy): + """Append DUPLICATE_POLICY property to params on CREATE + and ON_DUPLICATE on ADD. + """ + if duplicate_policy is not None: + if command == "TS.ADD": + params.extend(["ON_DUPLICATE", duplicate_policy]) + else: + params.extend(["DUPLICATE_POLICY", duplicate_policy]) + + @staticmethod + def _appendFilerByTs(params, ts_list): + """Append FILTER_BY_TS property to params.""" + if ts_list is not None: + params.extend(["FILTER_BY_TS", *ts_list]) + + @staticmethod + def _appendFilerByValue(params, min_value, max_value): + """Append FILTER_BY_VALUE property to params.""" + if min_value is not None and max_value is not None: + params.extend(["FILTER_BY_VALUE", min_value, max_value]) diff --git a/redis/commands/timeseries/info.py b/redis/commands/timeseries/info.py new file mode 100644 index 0000000000..3b89503f18 --- /dev/null +++ b/redis/commands/timeseries/info.py @@ -0,0 +1,82 @@ +from .utils import list_to_dict +from ..helpers import nativestr + + +class TSInfo(object): + """ + Hold information and statistics on the time-series. + Can be created using ``tsinfo`` command + https://oss.redis.com/redistimeseries/commands/#tsinfo. + """ + + rules = [] + labels = [] + sourceKey = None + chunk_count = None + memory_usage = None + total_samples = None + retention_msecs = None + last_time_stamp = None + first_time_stamp = None + + max_samples_per_chunk = None + chunk_size = None + duplicate_policy = None + + def __init__(self, args): + """ + Hold information and statistics on the time-series. + + The supported params that can be passed as args: + + rules: + A list of compaction rules of the time series. + sourceKey: + Key name for source time series in case the current series + is a target of a rule. + chunkCount: + Number of Memory Chunks used for the time series. + memoryUsage: + Total number of bytes allocated for the time series. + totalSamples: + Total number of samples in the time series. + labels: + A list of label-value pairs that represent the metadata + labels of the time series. + retentionTime: + Retention time, in milliseconds, for the time series. + lastTimestamp: + Last timestamp present in the time series. + firstTimestamp: + First timestamp present in the time series. + maxSamplesPerChunk: + Deprecated. + chunkSize: + Amount of memory, in bytes, allocated for data. + duplicatePolicy: + Policy that will define handling of duplicate samples. + + Can read more about on + https://oss.redis.com/redistimeseries/configuration/#duplicate_policy + """ + response = dict(zip(map(nativestr, args[::2]), args[1::2])) + self.rules = response["rules"] + self.source_key = response["sourceKey"] + self.chunk_count = response["chunkCount"] + self.memory_usage = response["memoryUsage"] + self.total_samples = response["totalSamples"] + self.labels = list_to_dict(response["labels"]) + self.retention_msecs = response["retentionTime"] + self.lastTimeStamp = response["lastTimestamp"] + self.first_time_stamp = response["firstTimestamp"] + if "maxSamplesPerChunk" in response: + self.max_samples_per_chunk = response["maxSamplesPerChunk"] + self.chunk_size = ( + self.max_samples_per_chunk * 16 + ) # backward compatible changes + if "chunkSize" in response: + self.chunk_size = response["chunkSize"] + if "duplicatePolicy" in response: + self.duplicate_policy = response["duplicatePolicy"] + if type(self.duplicate_policy) == bytes: + self.duplicate_policy = self.duplicate_policy.decode() diff --git a/redis/commands/timeseries/utils.py b/redis/commands/timeseries/utils.py new file mode 100644 index 0000000000..c33b7c591e --- /dev/null +++ b/redis/commands/timeseries/utils.py @@ -0,0 +1,49 @@ +from ..helpers import nativestr + + +def list_to_dict(aList): + return { + nativestr(aList[i][0]): nativestr(aList[i][1]) + for i in range(len(aList))} + + +def parse_range(response): + """Parse range response. Used by TS.RANGE and TS.REVRANGE.""" + return [tuple((r[0], float(r[1]))) for r in response] + + +def parse_m_range(response): + """Parse multi range response. Used by TS.MRANGE and TS.MREVRANGE.""" + res = [] + for item in response: + res.append( + {nativestr(item[0]): + [list_to_dict(item[1]), parse_range(item[2])]}) + return sorted(res, key=lambda d: list(d.keys())) + + +def parse_get(response): + """Parse get response. Used by TS.GET.""" + if not response: + return None + return int(response[0]), float(response[1]) + + +def parse_m_get(response): + """Parse multi get response. Used by TS.MGET.""" + res = [] + for item in response: + if not item[2]: + res.append( + {nativestr(item[0]): [list_to_dict(item[1]), None, None]}) + else: + res.append( + { + nativestr(item[0]): [ + list_to_dict(item[1]), + int(item[2][0]), + float(item[2][1]), + ] + } + ) + return sorted(res, key=lambda d: list(d.keys())) diff --git a/setup.py b/setup.py index 9788d2ed96..d0c81b40dd 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ "redis.commands", "redis.commands.json", "redis.commands.search", + "redis.commands.timeseries", ] ), url="https://github.com/redis/redis-py", diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py new file mode 100644 index 0000000000..b2df3feda5 --- /dev/null +++ b/tests/test_timeseries.py @@ -0,0 +1,593 @@ +import pytest +import time +from time import sleep +from .conftest import skip_ifmodversion_lt + + +@pytest.fixture +def client(modclient): + modclient.flushdb() + return modclient + + +@pytest.mark.redismod +def testCreate(client): + assert client.ts().create(1) + assert client.ts().create(2, retention_msecs=5) + assert client.ts().create(3, labels={"Redis": "Labs"}) + assert client.ts().create(4, retention_msecs=20, labels={"Time": "Series"}) + info = client.ts().info(4) + assert 20 == info.retention_msecs + assert "Series" == info.labels["Time"] + + # Test for a chunk size of 128 Bytes + assert client.ts().create("time-serie-1", chunk_size=128) + info = client.ts().info("time-serie-1") + assert 128, info.chunk_size + + +@pytest.mark.redismod +@skip_ifmodversion_lt("1.4.0", "timeseries") +def testCreateDuplicatePolicy(client): + # Test for duplicate policy + for duplicate_policy in ["block", "last", "first", "min", "max"]: + ts_name = "time-serie-ooo-{0}".format(duplicate_policy) + assert client.ts().create(ts_name, duplicate_policy=duplicate_policy) + info = client.ts().info(ts_name) + assert duplicate_policy == info.duplicate_policy + + +@pytest.mark.redismod +def testAlter(client): + assert client.ts().create(1) + assert 0 == client.ts().info(1).retention_msecs + assert client.ts().alter(1, retention_msecs=10) + assert {} == client.ts().info(1).labels + assert 10, client.ts().info(1).retention_msecs + assert client.ts().alter(1, labels={"Time": "Series"}) + assert "Series" == client.ts().info(1).labels["Time"] + assert 10 == client.ts().info(1).retention_msecs + + +# pipe = client.ts().pipeline() +# assert pipe.create(2) + + +@pytest.mark.redismod +@skip_ifmodversion_lt("1.4.0", "timeseries") +def testAlterDiplicatePolicy(client): + assert client.ts().create(1) + info = client.ts().info(1) + assert info.duplicate_policy is None + assert client.ts().alter(1, duplicate_policy="min") + info = client.ts().info(1) + assert "min" == info.duplicate_policy + + +@pytest.mark.redismod +def testAdd(client): + assert 1 == client.ts().add(1, 1, 1) + assert 2 == client.ts().add(2, 2, 3, retention_msecs=10) + assert 3 == client.ts().add(3, 3, 2, labels={"Redis": "Labs"}) + assert 4 == client.ts().add( + 4, 4, 2, retention_msecs=10, labels={"Redis": "Labs", "Time": "Series"} + ) + assert round(time.time()) == \ + round(float(client.ts().add(5, "*", 1)) / 1000) + + info = client.ts().info(4) + assert 10 == info.retention_msecs + assert "Labs" == info.labels["Redis"] + + # Test for a chunk size of 128 Bytes on TS.ADD + assert client.ts().add("time-serie-1", 1, 10.0, chunk_size=128) + info = client.ts().info("time-serie-1") + assert 128 == info.chunk_size + + +@pytest.mark.redismod +@skip_ifmodversion_lt("1.4.0", "timeseries") +def testAddDuplicatePolicy(client): + + # Test for duplicate policy BLOCK + assert 1 == client.ts().add("time-serie-add-ooo-block", 1, 5.0) + with pytest.raises(Exception): + client.ts().add( + "time-serie-add-ooo-block", + 1, + 5.0, + duplicate_policy="block" + ) + + # Test for duplicate policy LAST + assert 1 == client.ts().add("time-serie-add-ooo-last", 1, 5.0) + assert 1 == client.ts().add( + "time-serie-add-ooo-last", 1, 10.0, duplicate_policy="last" + ) + assert 10.0 == client.ts().get("time-serie-add-ooo-last")[1] + + # Test for duplicate policy FIRST + assert 1 == client.ts().add("time-serie-add-ooo-first", 1, 5.0) + assert 1 == client.ts().add( + "time-serie-add-ooo-first", 1, 10.0, duplicate_policy="first" + ) + assert 5.0 == client.ts().get("time-serie-add-ooo-first")[1] + + # Test for duplicate policy MAX + assert 1 == client.ts().add("time-serie-add-ooo-max", 1, 5.0) + assert 1 == client.ts().add( + "time-serie-add-ooo-max", 1, 10.0, duplicate_policy="max" + ) + assert 10.0 == client.ts().get("time-serie-add-ooo-max")[1] + + # Test for duplicate policy MIN + assert 1 == client.ts().add("time-serie-add-ooo-min", 1, 5.0) + assert 1 == client.ts().add( + "time-serie-add-ooo-min", 1, 10.0, duplicate_policy="min" + ) + assert 5.0 == client.ts().get("time-serie-add-ooo-min")[1] + + +@pytest.mark.redismod +def testMAdd(client): + client.ts().create("a") + assert [1, 2, 3] == \ + client.ts().madd([("a", 1, 5), ("a", 2, 10), ("a", 3, 15)]) + + +@pytest.mark.redismod +def testIncrbyDecrby(client): + for _ in range(100): + assert client.ts().incrby(1, 1) + sleep(0.001) + assert 100 == client.ts().get(1)[1] + for _ in range(100): + assert client.ts().decrby(1, 1) + sleep(0.001) + assert 0 == client.ts().get(1)[1] + + assert client.ts().incrby(2, 1.5, timestamp=5) + assert (5, 1.5) == client.ts().get(2) + assert client.ts().incrby(2, 2.25, timestamp=7) + assert (7, 3.75) == client.ts().get(2) + assert client.ts().decrby(2, 1.5, timestamp=15) + assert (15, 2.25) == client.ts().get(2) + + # Test for a chunk size of 128 Bytes on TS.INCRBY + assert client.ts().incrby("time-serie-1", 10, chunk_size=128) + info = client.ts().info("time-serie-1") + assert 128 == info.chunk_size + + # Test for a chunk size of 128 Bytes on TS.DECRBY + assert client.ts().decrby("time-serie-2", 10, chunk_size=128) + info = client.ts().info("time-serie-2") + assert 128 == info.chunk_size + + +@pytest.mark.redismod +def testCreateAndDeleteRule(client): + # test rule creation + time = 100 + client.ts().create(1) + client.ts().create(2) + client.ts().createrule(1, 2, "avg", 100) + for i in range(50): + client.ts().add(1, time + i * 2, 1) + client.ts().add(1, time + i * 2 + 1, 2) + client.ts().add(1, time * 2, 1.5) + assert round(client.ts().get(2)[1], 5) == 1.5 + info = client.ts().info(1) + assert info.rules[0][1] == 100 + + # test rule deletion + client.ts().deleterule(1, 2) + info = client.ts().info(1) + assert not info.rules + + +@pytest.mark.redismod +@skip_ifmodversion_lt("99.99.99", "timeseries") +def testDelRange(client): + try: + client.ts().delete("test", 0, 100) + except Exception as e: + assert e.__str__() != "" + + for i in range(100): + client.ts().add(1, i, i % 7) + assert 22 == client.ts().delete(1, 0, 21) + assert [] == client.ts().range(1, 0, 21) + assert [(22, 1.0)] == client.ts().range(1, 22, 22) + + +@pytest.mark.redismod +def testRange(client): + for i in range(100): + client.ts().add(1, i, i % 7) + assert 100 == len(client.ts().range(1, 0, 200)) + for i in range(100): + client.ts().add(1, i + 200, i % 7) + assert 200 == len(client.ts().range(1, 0, 500)) + # last sample isn't returned + assert 20 == len( + client.ts().range( + 1, + 0, + 500, + aggregation_type="avg", + bucket_size_msec=10 + ) + ) + assert 10 == len(client.ts().range(1, 0, 500, count=10)) + + +@pytest.mark.redismod +@skip_ifmodversion_lt("99.99.99", "timeseries") +def testRangeAdvanced(client): + for i in range(100): + client.ts().add(1, i, i % 7) + client.ts().add(1, i + 200, i % 7) + + assert 2 == len( + client.ts().range( + 1, + 0, + 500, + filter_by_ts=[i for i in range(10, 20)], + filter_by_min_value=1, + filter_by_max_value=2, + ) + ) + assert [(0, 10.0), (10, 1.0)] == client.ts().range( + 1, 0, 10, aggregation_type="count", bucket_size_msec=10, align="+" + ) + assert [(-5, 5.0), (5, 6.0)] == client.ts().range( + 1, 0, 10, aggregation_type="count", bucket_size_msec=10, align=5 + ) + + +@pytest.mark.redismod +@skip_ifmodversion_lt("99.99.99", "timeseries") +def testRevRange(client): + for i in range(100): + client.ts().add(1, i, i % 7) + assert 100 == len(client.ts().range(1, 0, 200)) + for i in range(100): + client.ts().add(1, i + 200, i % 7) + assert 200 == len(client.ts().range(1, 0, 500)) + # first sample isn't returned + assert 20 == len( + client.ts().revrange( + 1, + 0, + 500, + aggregation_type="avg", + bucket_size_msec=10 + ) + ) + assert 10 == len(client.ts().revrange(1, 0, 500, count=10)) + assert 2 == len( + client.ts().revrange( + 1, + 0, + 500, + filter_by_ts=[i for i in range(10, 20)], + filter_by_min_value=1, + filter_by_max_value=2, + ) + ) + assert [(10, 1.0), (0, 10.0)] == client.ts().revrange( + 1, 0, 10, aggregation_type="count", bucket_size_msec=10, align="+" + ) + assert [(1, 10.0), (-9, 1.0)] == client.ts().revrange( + 1, 0, 10, aggregation_type="count", bucket_size_msec=10, align=1 + ) + + +@pytest.mark.redismod +def testMultiRange(client): + client.ts().create(1, labels={"Test": "This", "team": "ny"}) + client.ts().create( + 2, + labels={"Test": "This", "Taste": "That", "team": "sf"} + ) + for i in range(100): + client.ts().add(1, i, i % 7) + client.ts().add(2, i, i % 11) + + res = client.ts().mrange(0, 200, filters=["Test=This"]) + assert 2 == len(res) + assert 100 == len(res[0]["1"][1]) + + res = client.ts().mrange(0, 200, filters=["Test=This"], count=10) + assert 10 == len(res[0]["1"][1]) + + for i in range(100): + client.ts().add(1, i + 200, i % 7) + res = client.ts().mrange( + 0, + 500, + filters=["Test=This"], + aggregation_type="avg", + bucket_size_msec=10 + ) + assert 2 == len(res) + assert 20 == len(res[0]["1"][1]) + + # test withlabels + assert {} == res[0]["1"][0] + res = client.ts().mrange(0, 200, filters=["Test=This"], with_labels=True) + assert {"Test": "This", "team": "ny"} == res[0]["1"][0] + + +@pytest.mark.redismod +@skip_ifmodversion_lt("99.99.99", "timeseries") +def testMultiRangeAdvanced(client): + client.ts().create(1, labels={"Test": "This", "team": "ny"}) + client.ts().create( + 2, + labels={"Test": "This", "Taste": "That", "team": "sf"} + ) + for i in range(100): + client.ts().add(1, i, i % 7) + client.ts().add(2, i, i % 11) + + # test with selected labels + res = client.ts().mrange( + 0, + 200, + filters=["Test=This"], + select_labels=["team"] + ) + assert {"team": "ny"} == res[0]["1"][0] + assert {"team": "sf"} == res[1]["2"][0] + + # test with filterby + res = client.ts().mrange( + 0, + 200, + filters=["Test=This"], + filter_by_ts=[i for i in range(10, 20)], + filter_by_min_value=1, + filter_by_max_value=2, + ) + assert [(15, 1.0), (16, 2.0)] == res[0]["1"][1] + + # test groupby + res = client.ts().mrange( + 0, + 3, + filters=["Test=This"], + groupby="Test", + reduce="sum" + ) + assert [(0, 0.0), (1, 2.0), (2, 4.0), (3, 6.0)] == res[0]["Test=This"][1] + res = client.ts().mrange( + 0, + 3, + filters=["Test=This"], + groupby="Test", + reduce="max" + ) + assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[0]["Test=This"][1] + res = client.ts().mrange( + 0, + 3, + filters=["Test=This"], + groupby="team", + reduce="min") + assert 2 == len(res) + assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[0]["team=ny"][1] + assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[1]["team=sf"][1] + + # test align + res = client.ts().mrange( + 0, + 10, + filters=["team=ny"], + aggregation_type="count", + bucket_size_msec=10, + align="-", + ) + assert [(0, 10.0), (10, 1.0)] == res[0]["1"][1] + res = client.ts().mrange( + 0, + 10, + filters=["team=ny"], + aggregation_type="count", + bucket_size_msec=10, + align=5, + ) + assert [(-5, 5.0), (5, 6.0)] == res[0]["1"][1] + + +@pytest.mark.redismod +@skip_ifmodversion_lt("99.99.99", "timeseries") +def testMultiReverseRange(client): + client.ts().create(1, labels={"Test": "This", "team": "ny"}) + client.ts().create( + 2, + labels={"Test": "This", "Taste": "That", "team": "sf"} + ) + for i in range(100): + client.ts().add(1, i, i % 7) + client.ts().add(2, i, i % 11) + + res = client.ts().mrange(0, 200, filters=["Test=This"]) + assert 2 == len(res) + assert 100 == len(res[0]["1"][1]) + + res = client.ts().mrange(0, 200, filters=["Test=This"], count=10) + assert 10 == len(res[0]["1"][1]) + + for i in range(100): + client.ts().add(1, i + 200, i % 7) + res = client.ts().mrevrange( + 0, + 500, + filters=["Test=This"], + aggregation_type="avg", + bucket_size_msec=10 + ) + assert 2 == len(res) + assert 20 == len(res[0]["1"][1]) + assert {} == res[0]["1"][0] + + # test withlabels + res = client.ts().mrevrange( + 0, + 200, + filters=["Test=This"], + with_labels=True + ) + assert {"Test": "This", "team": "ny"} == res[0]["1"][0] + + # test with selected labels + res = client.ts().mrevrange( + 0, + 200, + filters=["Test=This"], select_labels=["team"] + ) + assert {"team": "ny"} == res[0]["1"][0] + assert {"team": "sf"} == res[1]["2"][0] + + # test filterby + res = client.ts().mrevrange( + 0, + 200, + filters=["Test=This"], + filter_by_ts=[i for i in range(10, 20)], + filter_by_min_value=1, + filter_by_max_value=2, + ) + assert [(16, 2.0), (15, 1.0)] == res[0]["1"][1] + + # test groupby + res = client.ts().mrevrange( + 0, 3, filters=["Test=This"], groupby="Test", reduce="sum" + ) + assert [(3, 6.0), (2, 4.0), (1, 2.0), (0, 0.0)] == res[0]["Test=This"][1] + res = client.ts().mrevrange( + 0, 3, filters=["Test=This"], groupby="Test", reduce="max" + ) + assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[0]["Test=This"][1] + res = client.ts().mrevrange( + 0, 3, filters=["Test=This"], groupby="team", reduce="min" + ) + assert 2 == len(res) + assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[0]["team=ny"][1] + assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[1]["team=sf"][1] + + # test align + res = client.ts().mrevrange( + 0, + 10, + filters=["team=ny"], + aggregation_type="count", + bucket_size_msec=10, + align="-", + ) + assert [(10, 1.0), (0, 10.0)] == res[0]["1"][1] + res = client.ts().mrevrange( + 0, + 10, + filters=["team=ny"], + aggregation_type="count", + bucket_size_msec=10, + align=1, + ) + assert [(1, 10.0), (-9, 1.0)] == res[0]["1"][1] + + +@pytest.mark.redismod +def testGet(client): + name = "test" + client.ts().create(name) + assert client.ts().get(name) is None + client.ts().add(name, 2, 3) + assert 2 == client.ts().get(name)[0] + client.ts().add(name, 3, 4) + assert 4 == client.ts().get(name)[1] + + +@pytest.mark.redismod +def testMGet(client): + client.ts().create(1, labels={"Test": "This"}) + client.ts().create(2, labels={"Test": "This", "Taste": "That"}) + act_res = client.ts().mget(["Test=This"]) + exp_res = [{"1": [{}, None, None]}, {"2": [{}, None, None]}] + assert act_res == exp_res + client.ts().add(1, "*", 15) + client.ts().add(2, "*", 25) + res = client.ts().mget(["Test=This"]) + assert 15 == res[0]["1"][2] + assert 25 == res[1]["2"][2] + res = client.ts().mget(["Taste=That"]) + assert 25 == res[0]["2"][2] + + # test with_labels + assert {} == res[0]["2"][0] + res = client.ts().mget(["Taste=That"], with_labels=True) + assert {"Taste": "That", "Test": "This"} == res[0]["2"][0] + + +@pytest.mark.redismod +def testInfo(client): + client.ts().create( + 1, + retention_msecs=5, + labels={"currentLabel": "currentData"} + ) + info = client.ts().info(1) + assert 5 == info.retention_msecs + assert info.labels["currentLabel"] == "currentData" + + +@pytest.mark.redismod +@skip_ifmodversion_lt("1.4.0", "timeseries") +def testInfoDuplicatePolicy(client): + client.ts().create( + 1, + retention_msecs=5, + labels={"currentLabel": "currentData"} + ) + info = client.ts().info(1) + assert info.duplicate_policy is None + + client.ts().create("time-serie-2", duplicate_policy="min") + info = client.ts().info("time-serie-2") + assert "min" == info.duplicate_policy + + +@pytest.mark.redismod +def testQueryIndex(client): + client.ts().create(1, labels={"Test": "This"}) + client.ts().create(2, labels={"Test": "This", "Taste": "That"}) + assert 2 == len(client.ts().queryindex(["Test=This"])) + assert 1 == len(client.ts().queryindex(["Taste=That"])) + assert [2] == client.ts().queryindex(["Taste=That"]) + + +# +# @pytest.mark.redismod +# @pytest.mark.pipeline +# def testPipeline(client): +# pipeline = client.ts().pipeline() +# pipeline.create("with_pipeline") +# for i in range(100): +# pipeline.add("with_pipeline", i, 1.1 * i) +# pipeline.execute() + +# info = client.ts().info("with_pipeline") +# assert info.lastTimeStamp == 99 +# assert info.total_samples == 100 +# assert client.ts().get("with_pipeline")[1] == 99 * 1.1 + + +@pytest.mark.redismod +def testUncompressed(client): + client.ts().create("compressed") + client.ts().create("uncompressed", uncompressed=True) + compressed_info = client.ts().info("compressed") + uncompressed_info = client.ts().info("uncompressed") + assert compressed_info.memory_usage != uncompressed_info.memory_usage From d8adb8af45248ad4597f47ac871ce69990022046 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 28 Oct 2021 12:45:35 +0300 Subject: [PATCH 0221/1164] starting to clean the docs (#1657) --- CONTRIBUTING.md | 22 ++-- README.md | 328 +++++++++++++++++++++++------------------------- 2 files changed, 171 insertions(+), 179 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31170f3718..af067e7fdf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ community contributions! You may already know what you want to contribute \-- a fix for a bug you encountered, or a new feature your team wants to use. -If you don\'t know what to contribute, keep an open mind! Improving +If you don't know what to contribute, keep an open mind! Improving documentation, bug triaging, and writing tutorials are all examples of helpful contributions that mean less work for you. @@ -28,7 +28,7 @@ tutorials: ## Getting Started -Here\'s how to get started with your code contribution: +Here's how to get started with your code contribution: 1. Create your own fork of redis-py 2. Do the changes in your fork @@ -134,19 +134,19 @@ Please try at least versions of Docker. ### Security Vulnerabilities **NOTE**: If you find a security vulnerability, do NOT open an issue. -Email Andy McCurdy () instead. +Email [Redis Open Source ()](mailto:oss@redis.com) instead. In order to determine whether you are dealing with a security issue, ask yourself these two questions: -- Can I access something that\'s not mine, or something I shouldn\'t +- Can I access something that's not mine, or something I shouldn't have access to? - Can I disable something for other people? -If the answer to either of those two questions are \"yes\", then you\'re +If the answer to either of those two questions are *yes*, then you're probably dealing with a security issue. Note that even if you answer -\"no\" to both questions, you may still be dealing with a security -issue, so if you\'re unsure, just email Andy at . +*no* to both questions, you may still be dealing with a security +issue, so if you're unsure, just email [us](mailto:oss@redis.com). ### Everything Else @@ -160,17 +160,17 @@ When filing an issue, make sure to answer these five questions: ## How to Suggest a Feature or Enhancement -If you\'d like to contribute a new feature, make sure you check our +If you'd like to contribute a new feature, make sure you check our issue list to see if someone has already proposed it. Work may already -be under way on the feature you want \-- or we may have rejected a +be under way on the feature you want -- or we may have rejected a feature like it already. -If you don\'t see anything, open a new issue that describes the feature +If you don't see anything, open a new issue that describes the feature you would like and how it should work. ## Code Review Process The core team looks at Pull Requests on a regular basis. We will give feedback as as soon as possible. After feedback, we expect a response -within two weeks. After that time, we may close your PR if it isn\'t +within two weeks. After that time, we may close your PR if it isn't showing any activity. diff --git a/README.md b/README.md index 4bcea7be33..a01f820326 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,19 @@ The Python interface to the Redis key-value store. [![CI](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster) [![docs](https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE.txt) [![pypi](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/) [![codecov](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg?token=yenl5fzxxr)](https://codecov.io/gh/redis/redis-py) [![Total alerts](https://img.shields.io/lgtm/alerts/g/redis/redis-py.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/redis/redis-py/alerts/) +[Installation](##installation) | [Contributing](##contributing) | [Getting Started](##getting-started) | [Connecting To Redis](##connecting-to-redis) -## Python 2 Compatibility Note +--------------------------------------------- -redis-py 3.5.x will be the last version of redis-py that supports Python -2. The 3.5.x line will continue to get bug fixes and security patches -that support Python 2 until August 1, 2020. redis-py 4.0 will be the -next major version and will require Python 3.5+. ## Installation -redis-py requires a running Redis server. See [Redis\'s +redis-py requires a running Redis server. See [Redis's quickstart](https://redis.io/topics/quickstart) for installation instructions. @@ -45,12 +43,14 @@ $ python setup.py install ## Contributing -Want to contribute a feature, bug report, or report an issue? Check out +Want to contribute a feature, bug fix, or report an issue? Check out our [guide to -contributing](https://github.com/redis/redis-py/blob/master/CONTRIBUTING.rst). +contributing](https://github.com/redis/redis-py/blob/master/CONTRIBUTING.md). ## Getting Started +redis-py supports Python 3.6+. + ``` pycon >>> import redis >>> r = redis.Redis(host='localhost', port=6379, db=0) @@ -61,118 +61,26 @@ b'bar' ``` By default, all responses are returned as bytes in Python -3 and str in Python 2. The user is responsible for -decoding to Python 3 strings or Python 2 unicode objects. +3. If **all** string responses from a client should be decoded, the user -can specify decode_responses=True to -Redis.\_\_init\_\_. In this case, any Redis command that +can specify *decode_responses=True* in +```Redis.__init__```. In this case, any Redis command that returns a string type will be decoded with the encoding specified. -The default encoding is \"utf-8\", but this can be customized with the -encoding argument to the redis.Redis class. +The default encoding is utf-8, but this can be customized by specifiying the +encoding argument for the redis.Redis class. The encoding will be used to automatically encode any -strings passed to commands, such as key names and values. When -decode_responses=True, string data returned from commands -will be decoded with the same encoding. - -## Upgrading from redis-py 2.X to 3.0 - -redis-py 3.0 introduces many new features but required a number of -backwards incompatible changes to be made in the process. This section -attempts to provide an upgrade path for users migrating from 2.X to 3.0. - -### Python Version Support - -redis-py supports Python 3.5+. - -### Client Classes: Redis and StrictRedis - -redis-py 3.0 drops support for the legacy \"Redis\" client class. -\"StrictRedis\" has been renamed to \"Redis\" and an alias named -\"StrictRedis\" is provided so that users previously using -\"StrictRedis\" can continue to run unchanged. - -The 2.X \"Redis\" class provided alternative implementations of a few -commands. This confused users (rightfully so) and caused a number of -support issues. To make things easier going forward, it was decided to -drop support for these alternate implementations and instead focus on a -single client class. - -2.X users that are already using StrictRedis don\'t have to change the -class name. StrictRedis will continue to work for the foreseeable -future. - -2.X users that are using the Redis class will have to make changes if -they use any of the following commands: - -- SETEX: The argument order has changed. The new order is (name, time, - value). -- LREM: The argument order has changed. The new order is (name, num, - value). -- TTL and PTTL: The return value is now always an int and matches the - official Redis command (>0 indicates the timeout, -1 indicates that - the key exists but that it has no expire time set, -2 indicates that - the key does not exist) - -### SSL Connections - -redis-py 3.0 changes the default value of the -ssl_cert_reqs option from None to -\'required\'. See [Issue -1016](https://github.com/redis/redis-py/issues/1016). This change -enforces hostname validation when accepting a cert from a remote SSL -terminator. If the terminator doesn\'t properly set the hostname on the -cert this will cause redis-py 3.0 to raise a ConnectionError. - -This check can be disabled by setting ssl_cert_reqs to -None. Note that doing so removes the security check. Do so -at your own risk. - -Example with hostname verification using a local certificate bundle -(linux): - -``` pycon ->>> import redis ->>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, - ssl=True, - ssl_ca_certs='/etc/ssl/certs/ca-certificates.crt') ->>> r.set('foo', 'bar') -True ->>> r.get('foo') -b'bar' -``` - -Example with hostname verification using -[certifi](https://pypi.org/project/certifi/): - -``` pycon ->>> import redis, certifi ->>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, - ssl=True, ssl_ca_certs=certifi.where()) ->>> r.set('foo', 'bar') -True ->>> r.get('foo') -b'bar' -``` +strings passed to commands, such as key names and values. -Example turning off hostname verification (not recommended): -``` pycon ->>> import redis ->>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, - ssl=True, ssl_cert_reqs=None) ->>> r.set('foo', 'bar') -True ->>> r.get('foo') -b'bar' -``` +-------------------- ### MSET, MSETNX and ZADD These commands all accept a mapping of key/value pairs. In redis-py 2.X -this mapping could be specified as `*args` or as `**kwargs`. Both of +this mapping could be specified as **args* or as `**kwargs`. Both of these styles caused issues when Redis introduced optional flags to ZADD. Relying on `*args` caused issues with the optional argument order, especially in Python 2.7. Relying on `**kwargs` caused potential @@ -229,8 +137,8 @@ supports the Lua-based lock. In doing so, LuaLock has been renamed to Lock. This also means that redis-py Lock objects require Redis server 2.6 or greater. -2.X users that were explicitly referring to \"LuaLock\" will have to now -refer to \"Lock\" instead. +2.X users that were explicitly referring to *LuaLock* will have to now +refer to *Lock* instead. ### Locks as Context Managers @@ -240,7 +148,7 @@ This is more of a bug fix than a backwards incompatible change. However, given an error is now raised where none was before, this might alarm some users. -2.X users should make sure they\'re wrapping their lock code in a +2.X users should make sure they're wrapping their lock code in a try/catch like this: ``` python @@ -259,8 +167,8 @@ to adhere to the official command syntax. There are a few exceptions: - **SELECT**: Not implemented. See the explanation in the Thread Safety section below. -- **DEL**: \'del\' is a reserved keyword in the Python syntax. - Therefore redis-py uses \'delete\' instead. +- **DEL**: *del* is a reserved keyword in the Python syntax. + Therefore redis-py uses *delete* instead. - **MULTI/EXEC**: These are implemented as part of the Pipeline class. The pipeline is wrapped with the MULTI and EXEC statements by default when it is executed, which can be disabled by specifying @@ -273,14 +181,44 @@ to adhere to the official command syntax. There are a few exceptions: PUBLISH from the Redis client (see [this comment on issue #151](https://github.com/redis/redis-py/issues/151#issuecomment-1545015) for details). -- **SCAN/SSCAN/HSCAN/ZSCAN**: The \*SCAN commands are implemented as +- **SCAN/SSCAN/HSCAN/ZSCAN**: The *SCAN commands are implemented as they exist in the Redis documentation. In addition, each command has an equivalent iterator method. These are purely for convenience so - the user doesn\'t have to keep track of the cursor while iterating. + the user doesn't have to keep track of the cursor while iterating. Use the scan_iter/sscan_iter/hscan_iter/zscan_iter methods for this behavior. -## More Detail +## Connecting to Redis + +### Client Classes: Redis and StrictRedis + +redis-py 3.0 drops support for the legacy *Redis* client class. +*StrictRedis* has been renamed to *Redis* and an alias named +*StrictRedis* is provided so that users previously using +*StrictRedis* can continue to run unchanged. + +The 2.X *Redis* class provided alternative implementations of a few +commands. This confused users (rightfully so) and caused a number of +support issues. To make things easier going forward, it was decided to +drop support for these alternate implementations and instead focus on a +single client class. + +2.X users that are already using StrictRedis don\'t have to change the +class name. StrictRedis will continue to work for the foreseeable +future. + +2.X users that are using the Redis class will have to make changes if +they use any of the following commands: + +- SETEX: The argument order has changed. The new order is (name, time, + value). +- LREM: The argument order has changed. The new order is (name, num, + value). +- TTL and PTTL: The return value is now always an int and matches the + official Redis command (>0 indicates the timeout, -1 indicates that + the key exists but that it has no expire time set, -2 indicates that + the key does not exist) + ### Connection Pools @@ -355,7 +293,7 @@ option to a value less than 30. This option also works on any PubSub connection that is created from a client with `health_check_interval` enabled. PubSub users need to ensure -that `get_message()` or `listen()` are called more frequently than +that *get_message()* or `listen()` are called more frequently than `health_check_interval` seconds. It is assumed that most workloads already do this. @@ -363,6 +301,108 @@ If your PubSub use case doesn\'t call `get_message()` or `listen()` frequently, you should call `pubsub.check_health()` explicitly on a regularly basis. +### SSL Connections + +redis-py 3.0 changes the default value of the +ssl_cert_reqs option from None to +\'required\'. See [Issue +1016](https://github.com/redis/redis-py/issues/1016). This change +enforces hostname validation when accepting a cert from a remote SSL +terminator. If the terminator doesn\'t properly set the hostname on the +cert this will cause redis-py 3.0 to raise a ConnectionError. + +This check can be disabled by setting ssl_cert_reqs to +None. Note that doing so removes the security check. Do so +at your own risk. + +Example with hostname verification using a local certificate bundle +(linux): + +``` pycon +>>> import redis +>>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, + ssl_ca_certs='/etc/ssl/certs/ca-certificates.crt') +>>> r.set('foo', 'bar') +True +>>> r.get('foo') +b'bar' +``` + +Example with hostname verification using +[certifi](https://pypi.org/project/certifi/): + +``` pycon +>>> import redis, certifi +>>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, ssl_ca_certs=certifi.where()) +>>> r.set('foo', 'bar') +True +>>> r.get('foo') +b'bar' +``` + +Example turning off hostname verification (not recommended): + +``` pycon +>>> import redis +>>> r = redis.Redis(host='xxxxxx.cache.amazonaws.com', port=6379, db=0, + ssl=True, ssl_cert_reqs=None) +>>> r.set('foo', 'bar') +True +>>> r.get('foo') +b'bar' +``` + +### Sentinel support + +redis-py can be used together with [Redis +Sentinel](https://redis.io/topics/sentinel) to discover Redis nodes. You +need to have at least one Sentinel daemon running in order to use +redis-py's Sentinel support. + +Connecting redis-py to the Sentinel instance(s) is easy. You can use a +Sentinel connection to discover the master and slaves network addresses: + +``` pycon +>>> from redis.sentinel import Sentinel +>>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) +>>> sentinel.discover_master('mymaster') +('127.0.0.1', 6379) +>>> sentinel.discover_slaves('mymaster') +[('127.0.0.1', 6380)] +``` + +You can also create Redis client connections from a Sentinel instance. +You can connect to either the master (for write operations) or a slave +(for read-only operations). + +``` pycon +>>> master = sentinel.master_for('mymaster', socket_timeout=0.1) +>>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1) +>>> master.set('foo', 'bar') +>>> slave.get('foo') +b'bar' +``` + +The master and slave objects are normal Redis instances with their +connection pool bound to the Sentinel instance. When a Sentinel backed +client attempts to establish a connection, it first queries the Sentinel +servers to determine an appropriate host to connect to. If no server is +found, a MasterNotFoundError or SlaveNotFoundError is raised. Both +exceptions are subclasses of ConnectionError. + +When trying to connect to a slave client, the Sentinel connection pool +will iterate over the list of slaves until it finds one that can be +connected to. If no slaves can be connected to, a connection will be +established with the master. + +See [Guidelines for Redis clients with support for Redis +Sentinel](https://redis.io/topics/sentinel-clients) to learn more about +Redis Sentinel. + +-------------------------- + ### Parsers Parser classes provide a way to control how responses from the Redis @@ -875,52 +915,6 @@ just prior to pipeline execution. [True, 25] ``` -### Sentinel support - -redis-py can be used together with [Redis -Sentinel](https://redis.io/topics/sentinel) to discover Redis nodes. You -need to have at least one Sentinel daemon running in order to use -redis-py\'s Sentinel support. - -Connecting redis-py to the Sentinel instance(s) is easy. You can use a -Sentinel connection to discover the master and slaves network addresses: - -``` pycon ->>> from redis.sentinel import Sentinel ->>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) ->>> sentinel.discover_master('mymaster') -('127.0.0.1', 6379) ->>> sentinel.discover_slaves('mymaster') -[('127.0.0.1', 6380)] -``` - -You can also create Redis client connections from a Sentinel instance. -You can connect to either the master (for write operations) or a slave -(for read-only operations). - -``` pycon ->>> master = sentinel.master_for('mymaster', socket_timeout=0.1) ->>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1) ->>> master.set('foo', 'bar') ->>> slave.get('foo') -b'bar' -``` - -The master and slave objects are normal Redis instances with their -connection pool bound to the Sentinel instance. When a Sentinel backed -client attempts to establish a connection, it first queries the Sentinel -servers to determine an appropriate host to connect to. If no server is -found, a MasterNotFoundError or SlaveNotFoundError is raised. Both -exceptions are subclasses of ConnectionError. - -When trying to connect to a slave client, the Sentinel connection pool -will iterate over the list of slaves until it finds one that can be -connected to. If no slaves can be connected to, a connection will be -established with the master. - -See [Guidelines for Redis clients with support for Redis -Sentinel](https://redis.io/topics/sentinel-clients) to learn more about -Redis Sentinel. ### Scan Iterators @@ -947,18 +941,16 @@ Mode](https://redis.io/topics/cluster-tutorial). ### Author -redis-py is developed and maintained by Andy McCurdy -(). It can be found here: - +redis-py is developed and maintained by [Redis Inc](https://redis.com). It can be found [here]( +https://github.com/redis/redis-py), or downloaded from [pypi](https://pypi.org/project/redis/). Special thanks to: +- Andy McCurdy () the original author of redis-py. - Ludovico Magnocavallo, author of the original Python Redis client, from which some of the socket code is still used. - Alexander Solovyov for ideas on the generic response callback system. - Paul Hubbard for initial packaging support. -### Sponsored by - [![Redis](./docs/logo-redis.png)](https://www.redis.com) From 8178997e2838d01dafe14dcf0a1d2d6c6a20f051 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 28 Oct 2021 12:46:04 +0300 Subject: [PATCH 0222/1164] Adding vulture for static analysis (#1655) * Adding vulture for static analysis Removing dead code found previously by vulture in local runs. --- dev_requirements.txt | 3 ++- redis/connection.py | 1 - redis/features.py | 5 ----- tasks.py | 2 +- tox.ini | 29 +++++++++++------------------ whitelist.py | 12 ++++++++++++ 6 files changed, 26 insertions(+), 26 deletions(-) delete mode 100644 redis/features.py create mode 100644 whitelist.py diff --git a/dev_requirements.txt b/dev_requirements.txt index d3f91fef3d..aa9d8f9eee 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -3,4 +3,5 @@ pytest==6.2.5 tox==3.24.4 tox-docker==3.1.0 invoke==1.6.0 -pytest-cov>=3.0.0 \ No newline at end of file +pytest-cov>=3.0.0 +vulture>=2.3.0 diff --git a/redis/connection.py b/redis/connection.py index c99c550ecd..f5d6a38221 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -746,7 +746,6 @@ def can_read(self, timeout=0): sock = self._sock if not sock: self.connect() - sock = self._sock return self._parser.can_read(timeout) def read_response(self): diff --git a/redis/features.py b/redis/features.py deleted file mode 100644 index a96bac7c77..0000000000 --- a/redis/features.py +++ /dev/null @@ -1,5 +0,0 @@ -try: - import hiredis # noqa - HIREDIS_AVAILABLE = True -except ImportError: - HIREDIS_AVAILABLE = False diff --git a/tasks.py b/tasks.py index aa965c6902..4ca2242fa9 100644 --- a/tasks.py +++ b/tasks.py @@ -23,7 +23,7 @@ def devenv(c): @task def linters(c): """Run code linters""" - run("flake8") + run("tox -e linters") @task diff --git a/tox.ini b/tox.ini index 67b7e7575d..211f69e1fe 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ markers = [tox] minversion = 3.2.0 requires = tox-docker -envlist = {py35,py36,py37,py38,py39,pypy3}-{plain,hiredis}, flake8 +envlist = {py35,py36,py37,py38,py39,pypy3}-{plain,hiredis},linters [docker:master] name = master @@ -102,9 +102,12 @@ docker = lots-of-pythons commands = /usr/bin/echo -[testenv:flake8] +[testenv:linters] deps_files = dev_requirements.txt -commands = flake8 +docker= +commands = + flake8 + vulture redis whitelist.py --min-confidence 80 skipsdist = true skip_install = true @@ -114,18 +117,8 @@ basepython = pypy3 [testenv:pypy3-hiredis] basepython = pypy3 -#[testenv:codecov] -#deps = codecov -#commands = codecov -#passenv = -# REDIS_* -# CI -# CI_* -# CODECOV_* -# SHIPPABLE -# GITHUB_* -# VCS_* -# -#[testenv:covreport] -#deps = coverage -#commands = coverage report +[flake8] +exclude = + .venv, + .tox, + whitelist.py diff --git a/whitelist.py b/whitelist.py new file mode 100644 index 0000000000..891ccd6022 --- /dev/null +++ b/whitelist.py @@ -0,0 +1,12 @@ +exc_type # unused variable (/data/repos/redis/redis-py/redis/client.py:1045) +exc_value # unused variable (/data/repos/redis/redis-py/redis/client.py:1045) +traceback # unused variable (/data/repos/redis/redis-py/redis/client.py:1045) +exc_type # unused variable (/data/repos/redis/redis-py/redis/client.py:1211) +exc_value # unused variable (/data/repos/redis/redis-py/redis/client.py:1211) +traceback # unused variable (/data/repos/redis/redis-py/redis/client.py:1211) +exc_type # unused variable (/data/repos/redis/redis-py/redis/client.py:1589) +exc_value # unused variable (/data/repos/redis/redis-py/redis/client.py:1589) +traceback # unused variable (/data/repos/redis/redis-py/redis/client.py:1589) +exc_type # unused variable (/data/repos/redis/redis-py/redis/lock.py:156) +exc_value # unused variable (/data/repos/redis/redis-py/redis/lock.py:156) +traceback # unused variable (/data/repos/redis/redis-py/redis/lock.py:156) From e46dd85aa9e30a27106baf04ce22cb3e986857bb Mon Sep 17 00:00:00 2001 From: Anas Date: Tue, 2 Nov 2021 10:13:04 +0200 Subject: [PATCH 0223/1164] Added boolean parsing to PEXPIRE and PEXPIREAT (#1665) --- redis/client.py | 3 ++- tests/test_commands.py | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/redis/client.py b/redis/client.py index 986af7cfba..935d9dd891 100755 --- a/redis/client.py +++ b/redis/client.py @@ -618,7 +618,8 @@ class Redis(RedisModuleCommands, CoreCommands, object): """ RESPONSE_CALLBACKS = { **string_keys_to_dict( - 'AUTH COPY EXPIRE EXPIREAT HEXISTS HMSET LMOVE BLMOVE MOVE ' + 'AUTH COPY EXPIRE EXPIREAT PEXPIRE PEXPIREAT ' + 'HEXISTS HMSET LMOVE BLMOVE MOVE ' 'MSETNX PERSIST PSETEX RENAMENX SISMEMBER SMOVE SETEX SETNX', bool ), diff --git a/tests/test_commands.py b/tests/test_commands.py index 6d65931539..8aa584b224 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -834,9 +834,9 @@ def test_exists_contains(self, r): assert 'a' in r def test_expire(self, r): - assert not r.expire('a', 10) + assert r.expire('a', 10) is False r['a'] = 'foo' - assert r.expire('a', 10) + assert r.expire('a', 10) is True assert 0 < r.ttl('a') <= 10 assert r.persist('a') assert r.ttl('a') == -1 @@ -844,18 +844,18 @@ def test_expire(self, r): def test_expireat_datetime(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) r['a'] = 'foo' - assert r.expireat('a', expire_at) + assert r.expireat('a', expire_at) is True assert 0 < r.ttl('a') <= 61 def test_expireat_no_key(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - assert not r.expireat('a', expire_at) + assert r.expireat('a', expire_at) is False def test_expireat_unixtime(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) r['a'] = 'foo' expire_at_seconds = int(time.mktime(expire_at.timetuple())) - assert r.expireat('a', expire_at_seconds) + assert r.expireat('a', expire_at_seconds) is True assert 0 < r.ttl('a') <= 61 def test_get_and_set(self, r): @@ -998,9 +998,9 @@ def test_msetnx(self, r): @skip_if_server_version_lt('2.6.0') def test_pexpire(self, r): - assert not r.pexpire('a', 60000) + assert r.pexpire('a', 60000) is False r['a'] = 'foo' - assert r.pexpire('a', 60000) + assert r.pexpire('a', 60000) is True assert 0 < r.pttl('a') <= 60000 assert r.persist('a') assert r.pttl('a') == -1 @@ -1009,20 +1009,20 @@ def test_pexpire(self, r): def test_pexpireat_datetime(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) r['a'] = 'foo' - assert r.pexpireat('a', expire_at) + assert r.pexpireat('a', expire_at) is True assert 0 < r.pttl('a') <= 61000 @skip_if_server_version_lt('2.6.0') def test_pexpireat_no_key(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - assert not r.pexpireat('a', expire_at) + assert r.pexpireat('a', expire_at) is False @skip_if_server_version_lt('2.6.0') def test_pexpireat_unixtime(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) r['a'] = 'foo' expire_at_seconds = int(time.mktime(expire_at.timetuple())) * 1000 - assert r.pexpireat('a', expire_at_seconds) + assert r.pexpireat('a', expire_at_seconds) is True assert 0 < r.pttl('a') <= 61000 @skip_if_server_version_lt('2.6.0') @@ -1040,9 +1040,9 @@ def test_psetex_timedelta(self, r): @skip_if_server_version_lt('2.6.0') def test_pttl(self, r): - assert not r.pexpire('a', 10000) + assert r.pexpire('a', 10000) is False r['a'] = '1' - assert r.pexpire('a', 10000) + assert r.pexpire('a', 10000) is True assert 0 < r.pttl('a') <= 10000 assert r.persist('a') assert r.pttl('a') == -1 From 75c5d5974ed3f6a5069f601f4c0d42ad3ae48c76 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 2 Nov 2021 11:55:07 +0200 Subject: [PATCH 0224/1164] Improved JSON accuracy (#1666) --- redis/commands/helpers.py | 2 + redis/commands/json/__init__.py | 8 ++- redis/commands/json/commands.py | 93 +++++++++++++++------------ redis/commands/json/decoders.py | 12 ++++ redis/commands/json/path.py | 11 +--- requirements.txt | 1 + setup.py | 3 + tests/test_json.py | 107 ++++++++++++++++++++++++-------- tox.ini | 4 +- 9 files changed, 165 insertions(+), 76 deletions(-) create mode 100644 redis/commands/json/decoders.py create mode 100644 requirements.txt diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index a92c02503e..48ee5568e6 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -22,6 +22,8 @@ def nativestr(x): def delist(x): """Given a list of binaries, return the stringified version.""" + if x is None: + return x return [nativestr(obj) for obj in x] diff --git a/redis/commands/json/__init__.py b/redis/commands/json/__init__.py index 978370553a..3149bb8f50 100644 --- a/redis/commands/json/__init__.py +++ b/redis/commands/json/__init__.py @@ -1,5 +1,9 @@ from json import JSONDecoder, JSONEncoder +from .decoders import ( + int_or_list, + int_or_none +) from .helpers import bulk_of_jsons from ..helpers import nativestr, delist from .commands import JSONCommands @@ -48,13 +52,13 @@ def __init__( "JSON.ARRAPPEND": int, "JSON.ARRINDEX": int, "JSON.ARRINSERT": int, - "JSON.ARRLEN": int, + "JSON.ARRLEN": int_or_none, "JSON.ARRPOP": self._decode, "JSON.ARRTRIM": int, "JSON.OBJLEN": int, "JSON.OBJKEYS": delist, # "JSON.RESP": delist, - "JSON.DEBUG": int, + "JSON.DEBUG": int_or_list, } self.client = client diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py index 2f8039f8bf..fb00e220aa 100644 --- a/redis/commands/json/commands.py +++ b/redis/commands/json/commands.py @@ -1,5 +1,7 @@ -from .path import Path, str_path +from .path import Path from .helpers import decode_dict_keys +from deprecated import deprecated +from redis.exceptions import DataError class JSONCommands: @@ -9,7 +11,7 @@ def arrappend(self, name, path=Path.rootPath(), *args): """Append the objects ``args`` to the array under the ``path` in key ``name``. """ - pieces = [name, str_path(path)] + pieces = [name, str(path)] for o in args: pieces.append(self._encode(o)) return self.execute_command("JSON.ARRAPPEND", *pieces) @@ -23,7 +25,7 @@ def arrindex(self, name, path, scalar, start=0, stop=-1): and exclusive ``stop`` indices. """ return self.execute_command( - "JSON.ARRINDEX", name, str_path(path), self._encode(scalar), + "JSON.ARRINDEX", name, str(path), self._encode(scalar), start, stop ) @@ -31,67 +33,64 @@ def arrinsert(self, name, path, index, *args): """Insert the objects ``args`` to the array at index ``index`` under the ``path` in key ``name``. """ - pieces = [name, str_path(path), index] + pieces = [name, str(path), index] for o in args: pieces.append(self._encode(o)) return self.execute_command("JSON.ARRINSERT", *pieces) - def forget(self, name, path=Path.rootPath()): - """Alias for jsondel (delete the JSON value).""" - return self.execute_command("JSON.FORGET", name, str_path(path)) - def arrlen(self, name, path=Path.rootPath()): """Return the length of the array JSON value under ``path`` at key``name``. """ - return self.execute_command("JSON.ARRLEN", name, str_path(path)) + return self.execute_command("JSON.ARRLEN", name, str(path)) def arrpop(self, name, path=Path.rootPath(), index=-1): """Pop the element at ``index`` in the array JSON value under ``path`` at key ``name``. """ - return self.execute_command("JSON.ARRPOP", name, str_path(path), index) + return self.execute_command("JSON.ARRPOP", name, str(path), index) def arrtrim(self, name, path, start, stop): """Trim the array JSON value under ``path`` at key ``name`` to the inclusive range given by ``start`` and ``stop``. """ - return self.execute_command("JSON.ARRTRIM", name, str_path(path), + return self.execute_command("JSON.ARRTRIM", name, str(path), start, stop) def type(self, name, path=Path.rootPath()): """Get the type of the JSON value under ``path`` from key ``name``.""" - return self.execute_command("JSON.TYPE", name, str_path(path)) + return self.execute_command("JSON.TYPE", name, str(path)) def resp(self, name, path=Path.rootPath()): """Return the JSON value under ``path`` at key ``name``.""" - return self.execute_command("JSON.RESP", name, str_path(path)) + return self.execute_command("JSON.RESP", name, str(path)) def objkeys(self, name, path=Path.rootPath()): """Return the key names in the dictionary JSON value under ``path`` at key ``name``.""" - return self.execute_command("JSON.OBJKEYS", name, str_path(path)) + return self.execute_command("JSON.OBJKEYS", name, str(path)) def objlen(self, name, path=Path.rootPath()): """Return the length of the dictionary JSON value under ``path`` at key ``name``. """ - return self.execute_command("JSON.OBJLEN", name, str_path(path)) + return self.execute_command("JSON.OBJLEN", name, str(path)) def numincrby(self, name, path, number): """Increment the numeric (integer or floating point) JSON value under ``path`` at key ``name`` by the provided ``number``. """ return self.execute_command( - "JSON.NUMINCRBY", name, str_path(path), self._encode(number) + "JSON.NUMINCRBY", name, str(path), self._encode(number) ) + @deprecated(version='4.0.0', reason='deprecated since redisjson 1.0.0') def nummultby(self, name, path, number): """Multiply the numeric (integer or floating point) JSON value under ``path`` at key ``name`` with the provided ``number``. """ return self.execute_command( - "JSON.NUMMULTBY", name, str_path(path), self._encode(number) + "JSON.NUMMULTBY", name, str(path), self._encode(number) ) def clear(self, name, path=Path.rootPath()): @@ -102,11 +101,14 @@ def clear(self, name, path=Path.rootPath()): Return the count of cleared paths (ignoring non-array and non-objects paths). """ - return self.execute_command("JSON.CLEAR", name, str_path(path)) + return self.execute_command("JSON.CLEAR", name, str(path)) + + def delete(self, key, path=Path.rootPath()): + """Delete the JSON value stored at key ``key`` under ``path``.""" + return self.execute_command("JSON.DEL", key, str(path)) - def delete(self, name, path=Path.rootPath()): - """Delete the JSON value stored at key ``name`` under ``path``.""" - return self.execute_command("JSON.DEL", name, str_path(path)) + # forget is an alias for delete + forget = delete def get(self, name, *args, no_escape=False): """ @@ -125,7 +127,7 @@ def get(self, name, *args, no_escape=False): else: for p in args: - pieces.append(str_path(p)) + pieces.append(str(p)) # Handle case where key doesn't exist. The JSONDecoder would raise a # TypeError exception since it can't decode None @@ -134,13 +136,14 @@ def get(self, name, *args, no_escape=False): except TypeError: return None - def mget(self, path, *args): - """Get the objects stored as a JSON values under ``path`` from keys - ``args``. + def mget(self, keys, path): + """ + Get the objects stored as a JSON values under ``path``. ``keys`` + is a list of one or more keys. """ pieces = [] - pieces.extend(args) - pieces.append(str_path(path)) + pieces += keys + pieces.append(str(path)) return self.execute_command("JSON.MGET", *pieces) def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): @@ -155,7 +158,7 @@ def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): if decode_keys: obj = decode_dict_keys(obj) - pieces = [name, str_path(path), self._encode(obj)] + pieces = [name, str(path), self._encode(obj)] # Handle existential modifiers if nx and xx: @@ -169,29 +172,43 @@ def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): pieces.append("XX") return self.execute_command("JSON.SET", *pieces) - def strlen(self, name, path=Path.rootPath()): + def strlen(self, name, path=None): """Return the length of the string JSON value under ``path`` at key ``name``. """ - return self.execute_command("JSON.STRLEN", name, str_path(path)) + pieces = [name] + if path is not None: + pieces.append(str(path)) + return self.execute_command("JSON.STRLEN", *pieces) def toggle(self, name, path=Path.rootPath()): """Toggle boolean value under ``path`` at key ``name``. returning the new value. """ - return self.execute_command("JSON.TOGGLE", name, str_path(path)) + return self.execute_command("JSON.TOGGLE", name, str(path)) - def strappend(self, name, string, path=Path.rootPath()): - """Append to the string JSON value under ``path`` at key ``name`` - the provided ``string``. + def strappend(self, name, value, path=Path.rootPath()): + """Append to the string JSON value. If two options are specified after + the key name, the path is determined to be the first. If a single + option is passed, then the rootpath (i.e Path.rootPath()) is used. """ + pieces = [name, str(path), value] return self.execute_command( - "JSON.STRAPPEND", name, str_path(path), self._encode(string) + "JSON.STRAPPEND", *pieces ) - def debug(self, name, path=Path.rootPath()): + def debug(self, subcommand, key=None, path=Path.rootPath()): """Return the memory usage in bytes of a value under ``path`` from key ``name``. """ - return self.execute_command("JSON.DEBUG", "MEMORY", - name, str_path(path)) + valid_subcommands = ["MEMORY", "HELP"] + if subcommand not in valid_subcommands: + raise DataError("The only valid subcommands are ", + str(valid_subcommands)) + pieces = [subcommand] + if subcommand == "MEMORY": + if key is None: + raise DataError("No key specified") + pieces.append(key) + pieces.append(str(path)) + return self.execute_command("JSON.DEBUG", *pieces) diff --git a/redis/commands/json/decoders.py b/redis/commands/json/decoders.py new file mode 100644 index 0000000000..0ee102a433 --- /dev/null +++ b/redis/commands/json/decoders.py @@ -0,0 +1,12 @@ +def int_or_list(b): + if isinstance(b, int): + return b + else: + return b + + +def int_or_none(b): + if b is None: + return None + if isinstance(b, int): + return b diff --git a/redis/commands/json/path.py b/redis/commands/json/path.py index dff86482df..6d87045155 100644 --- a/redis/commands/json/path.py +++ b/redis/commands/json/path.py @@ -1,11 +1,3 @@ -def str_path(p): - """Return the string representation of a path if it is of class Path.""" - if isinstance(p, Path): - return p.strPath - else: - return p - - class Path(object): """This class represents a path in a JSON value.""" @@ -19,3 +11,6 @@ def rootPath(): def __init__(self, path): """Make a new path based on the string representation in `path`.""" self.strPath = path + + def __repr__(self): + return self.strPath diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..9f8d5502da --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +deprecated diff --git a/setup.py b/setup.py index d0c81b40dd..6c712bd1d4 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,9 @@ author="Redis Inc.", author_email="oss@redis.com", python_requires=">=3.6", + install_requires=[ + 'deprecated' + ], classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Console", diff --git a/tests/test_json.py b/tests/test_json.py index 83fbf28669..f62346f762 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -74,27 +74,28 @@ def test_jsonsetexistentialmodifiersshouldsucceed(client): def test_mgetshouldsucceed(client): client.json().set("1", Path.rootPath(), 1) client.json().set("2", Path.rootPath(), 2) - r = client.json().mget(Path.rootPath(), "1", "2") - e = [1, 2] - assert e == r + assert client.json().mget(["1"], Path.rootPath()) == [1] + + assert client.json().mget([1, 2], Path.rootPath()) == [1, 2] @pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "ReJSON") # todo: update after the release -def test_clearShouldSucceed(client): +def test_clear(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 1 == client.json().clear("arr", Path.rootPath()) assert [] == client.json().get("arr") @pytest.mark.redismod -def test_typeshouldsucceed(client): +def test_type(client): client.json().set("1", Path.rootPath(), 1) + assert b"integer" == client.json().type("1", Path.rootPath()) assert b"integer" == client.json().type("1") @pytest.mark.redismod -def test_numincrbyshouldsucceed(client): +def test_numincrby(client): client.json().set("num", Path.rootPath(), 1) assert 2 == client.json().numincrby("num", Path.rootPath(), 1) assert 2.5 == client.json().numincrby("num", Path.rootPath(), 0.5) @@ -102,16 +103,18 @@ def test_numincrbyshouldsucceed(client): @pytest.mark.redismod -def test_nummultbyshouldsucceed(client): +def test_nummultby(client): client.json().set("num", Path.rootPath(), 1) - assert 2 == client.json().nummultby("num", Path.rootPath(), 2) - assert 5 == client.json().nummultby("num", Path.rootPath(), 2.5) - assert 2.5 == client.json().nummultby("num", Path.rootPath(), 0.5) + + with pytest.deprecated_call(): + assert 2 == client.json().nummultby("num", Path.rootPath(), 2) + assert 5 == client.json().nummultby("num", Path.rootPath(), 2.5) + assert 2.5 == client.json().nummultby("num", Path.rootPath(), 0.5) @pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "ReJSON") # todo: update after the release -def test_toggleShouldSucceed(client): +def test_toggle(client): client.json().set("bool", Path.rootPath(), False) assert client.json().toggle("bool", Path.rootPath()) assert not client.json().toggle("bool", Path.rootPath()) @@ -122,28 +125,37 @@ def test_toggleShouldSucceed(client): @pytest.mark.redismod -def test_strappendshouldsucceed(client): - client.json().set("str", Path.rootPath(), "foo") - assert 6 == client.json().strappend("str", "bar", Path.rootPath()) - assert "foobar" == client.json().get("str", Path.rootPath()) +def test_strappend(client): + client.json().set("jsonkey", Path.rootPath(), 'foo') + import json + assert 6 == client.json().strappend("jsonkey", json.dumps('bar')) + with pytest.raises(redis.exceptions.ResponseError): + assert 6 == client.json().strappend("jsonkey", 'bar') + assert "foobar" == client.json().get("jsonkey", Path.rootPath()) @pytest.mark.redismod def test_debug(client): client.json().set("str", Path.rootPath(), "foo") - assert 24 == client.json().debug("str", Path.rootPath()) + assert 24 == client.json().debug("MEMORY", "str", Path.rootPath()) + assert 24 == client.json().debug("MEMORY", "str") + + # technically help is valid + assert isinstance(client.json().debug("HELP"), list) @pytest.mark.redismod -def test_strlenshouldsucceed(client): +def test_strlen(client): client.json().set("str", Path.rootPath(), "foo") assert 3 == client.json().strlen("str", Path.rootPath()) - client.json().strappend("str", "bar", Path.rootPath()) + import json + client.json().strappend("str", json.dumps("bar"), Path.rootPath()) assert 6 == client.json().strlen("str", Path.rootPath()) + assert 6 == client.json().strlen("str") @pytest.mark.redismod -def test_arrappendshouldsucceed(client): +def test_arrappend(client): client.json().set("arr", Path.rootPath(), [1]) assert 2 == client.json().arrappend("arr", Path.rootPath(), 2) assert 4 == client.json().arrappend("arr", Path.rootPath(), 3, 4) @@ -151,14 +163,14 @@ def test_arrappendshouldsucceed(client): @pytest.mark.redismod -def testArrIndexShouldSucceed(client): +def test_arrindex(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 1 == client.json().arrindex("arr", Path.rootPath(), 1) assert -1 == client.json().arrindex("arr", Path.rootPath(), 1, 2) @pytest.mark.redismod -def test_arrinsertshouldsucceed(client): +def test_arrinsert(client): client.json().set("arr", Path.rootPath(), [0, 4]) assert 5 - -client.json().arrinsert( "arr", @@ -172,15 +184,22 @@ def test_arrinsertshouldsucceed(client): ) assert [0, 1, 2, 3, 4] == client.json().get("arr") + # test prepends + client.json().set("val2", Path.rootPath(), [5, 6, 7, 8, 9]) + client.json().arrinsert("val2", Path.rootPath(), 0, ['some', 'thing']) + assert client.json().get("val2") == [["some", "thing"], 5, 6, 7, 8, 9] + @pytest.mark.redismod -def test_arrlenshouldsucceed(client): +def test_arrlen(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 5 == client.json().arrlen("arr", Path.rootPath()) + assert 5 == client.json().arrlen("arr") + assert client.json().arrlen('fakekey') is None @pytest.mark.redismod -def test_arrpopshouldsucceed(client): +def test_arrpop(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 4 == client.json().arrpop("arr", Path.rootPath(), 4) assert 3 == client.json().arrpop("arr", Path.rootPath(), -1) @@ -188,25 +207,50 @@ def test_arrpopshouldsucceed(client): assert 0 == client.json().arrpop("arr", Path.rootPath(), 0) assert [1] == client.json().get("arr") + # test out of bounds + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 4 == client.json().arrpop("arr", Path.rootPath(), 99) + + # none test + client.json().set("arr", Path.rootPath(), []) + assert client.json().arrpop("arr") is None + @pytest.mark.redismod -def test_arrtrimshouldsucceed(client): +def test_arrtrim(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 3 == client.json().arrtrim("arr", Path.rootPath(), 1, 3) assert [1, 2, 3] == client.json().get("arr") + # <0 test, should be 0 equivalent + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 0 == client.json().arrtrim("arr", Path.rootPath(), -1, 3) + + # testing stop > end + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 2 == client.json().arrtrim("arr", Path.rootPath(), 3, 99) + + # start > array size and stop + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 0 == client.json().arrtrim("arr", Path.rootPath(), 9, 1) + + # all larger + client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) + assert 0 == client.json().arrtrim("arr", Path.rootPath(), 9, 11) + @pytest.mark.redismod -def test_respshouldsucceed(client): +def test_resp(client): obj = {"foo": "bar", "baz": 1, "qaz": True} client.json().set("obj", Path.rootPath(), obj) assert b"bar" == client.json().resp("obj", Path("foo")) assert 1 == client.json().resp("obj", Path("baz")) assert client.json().resp("obj", Path("qaz")) + assert isinstance(client.json().resp("obj"), list) @pytest.mark.redismod -def test_objkeysshouldsucceed(client): +def test_objkeys(client): obj = {"foo": "bar", "baz": "qaz"} client.json().set("obj", Path.rootPath(), obj) keys = client.json().objkeys("obj", Path.rootPath()) @@ -215,13 +259,22 @@ def test_objkeysshouldsucceed(client): exp.sort() assert exp == keys + client.json().set("obj", Path.rootPath(), obj) + keys = client.json().objkeys("obj") + assert keys == list(obj.keys()) + + assert client.json().objkeys("fakekey") is None + @pytest.mark.redismod -def test_objlenshouldsucceed(client): +def test_objlen(client): obj = {"foo": "bar", "baz": "qaz"} client.json().set("obj", Path.rootPath(), obj) assert len(obj) == client.json().objlen("obj", Path.rootPath()) + client.json().set("obj", Path.rootPath(), obj) + assert len(obj) == client.json().objlen("obj") + # @pytest.mark.pipeline # @pytest.mark.redismod diff --git a/tox.ini b/tox.ini index 211f69e1fe..f09b3a831c 100644 --- a/tox.ini +++ b/tox.ini @@ -75,7 +75,9 @@ volumes = bind:rw:{toxinidir}:/data [testenv] -deps = -r {toxinidir}/dev_requirements.txt +deps = + -r {toxinidir}/requirements.txt + -r {toxinidir}/dev_requirements.txt docker = master replica From 72b49263f86f32c9df945433f21d3f3a7444d1a0 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 3 Nov 2021 18:23:50 +0200 Subject: [PATCH 0225/1164] SMISMEMBER support (#1667) --- redis/commands/core.py | 8 ++++++++ tests/test_commands.py | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/redis/commands/core.py b/redis/commands/core.py index 6512b45a42..8dba5a2abf 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1768,6 +1768,14 @@ def smembers(self, name): """Return all members of the set ``name``""" return self.execute_command('SMEMBERS', name) + def smismember(self, name, values, *args): + """ + Return whether each value in ``values`` is a member of the set ``name`` + as a list of ``bool`` in the order of ``values`` + """ + args = list_or_args(values, args) + return self.execute_command('SMISMEMBER', name, *args) + def smove(self, src, dst, value): """Move ``value`` from set ``src`` to set ``dst`` atomically""" return self.execute_command('SMOVE', src, dst, value) diff --git a/tests/test_commands.py b/tests/test_commands.py index 8aa584b224..723612af6b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1577,6 +1577,13 @@ def test_smembers(self, r): r.sadd('a', '1', '2', '3') assert r.smembers('a') == {b'1', b'2', b'3'} + @skip_if_server_version_lt('6.2.0') + def test_smismember(self, r): + r.sadd('a', '1', '2', '3') + result_list = [True, False, True, True] + assert r.smismember('a', '1', '4', '2', '3') == result_list + assert r.smismember('a', ['1', '4', '2', '3']) == result_list + def test_smove(self, r): r.sadd('a', 'a1', 'a2') r.sadd('b', 'b1', 'b2') From 8d3c61598706eb049caa66a23501018f2f416673 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 4 Nov 2021 13:20:31 +0200 Subject: [PATCH 0226/1164] Support for json multipath ($) (#1663) --- redis/commands/helpers.py | 5 +- redis/commands/json/__init__.py | 45 +- redis/commands/json/commands.py | 4 +- redis/commands/json/decoders.py | 65 +- redis/commands/json/helpers.py | 25 - tests/conftest.py | 3 +- tests/test_json.py | 1133 ++++++++++++++++++++++++++++++- tests/testdata/jsontestdata.py | 617 +++++++++++++++++ 8 files changed, 1823 insertions(+), 74 deletions(-) delete mode 100644 redis/commands/json/helpers.py create mode 100644 tests/testdata/jsontestdata.py diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index 48ee5568e6..2a4298cb2f 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -17,7 +17,10 @@ def list_or_args(keys, args): def nativestr(x): """Return the decoded binary string, or a string, depending on type.""" - return x.decode("utf-8", "replace") if isinstance(x, bytes) else x + r = x.decode("utf-8", "replace") if isinstance(x, bytes) else x + if r == 'null': + return + return r def delist(x): diff --git a/redis/commands/json/__init__.py b/redis/commands/json/__init__.py index 3149bb8f50..d00627efce 100644 --- a/redis/commands/json/__init__.py +++ b/redis/commands/json/__init__.py @@ -1,11 +1,10 @@ -from json import JSONDecoder, JSONEncoder +from json import JSONDecoder, JSONEncoder, JSONDecodeError from .decoders import ( - int_or_list, - int_or_none + decode_list, + bulk_of_jsons, ) -from .helpers import bulk_of_jsons -from ..helpers import nativestr, delist +from ..helpers import nativestr from .commands import JSONCommands @@ -46,19 +45,19 @@ def __init__( "JSON.SET": lambda r: r and nativestr(r) == "OK", "JSON.NUMINCRBY": self._decode, "JSON.NUMMULTBY": self._decode, - "JSON.TOGGLE": lambda b: b == b"true", - "JSON.STRAPPEND": int, - "JSON.STRLEN": int, - "JSON.ARRAPPEND": int, - "JSON.ARRINDEX": int, - "JSON.ARRINSERT": int, - "JSON.ARRLEN": int_or_none, + "JSON.TOGGLE": self._decode, + "JSON.STRAPPEND": self._decode, + "JSON.STRLEN": self._decode, + "JSON.ARRAPPEND": self._decode, + "JSON.ARRINDEX": self._decode, + "JSON.ARRINSERT": self._decode, + "JSON.ARRLEN": self._decode, "JSON.ARRPOP": self._decode, - "JSON.ARRTRIM": int, - "JSON.OBJLEN": int, - "JSON.OBJKEYS": delist, - # "JSON.RESP": delist, - "JSON.DEBUG": int_or_list, + "JSON.ARRTRIM": self._decode, + "JSON.OBJLEN": self._decode, + "JSON.OBJKEYS": self._decode, + "JSON.RESP": self._decode, + "JSON.DEBUG": self._decode, } self.client = client @@ -77,9 +76,17 @@ def _decode(self, obj): return obj try: - return self.__decoder__.decode(obj) + x = self.__decoder__.decode(obj) + if x is None: + raise TypeError + return x except TypeError: - return self.__decoder__.decode(obj.decode()) + try: + return self.__decoder__.decode(obj.decode()) + except AttributeError: + return decode_list(obj) + except (AttributeError, JSONDecodeError): + return decode_list(obj) def _encode(self, obj): """Get the encoder.""" diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py index fb00e220aa..716741c19b 100644 --- a/redis/commands/json/commands.py +++ b/redis/commands/json/commands.py @@ -1,5 +1,5 @@ from .path import Path -from .helpers import decode_dict_keys +from .decoders import decode_dict_keys from deprecated import deprecated from redis.exceptions import DataError @@ -192,7 +192,7 @@ def strappend(self, name, value, path=Path.rootPath()): the key name, the path is determined to be the first. If a single option is passed, then the rootpath (i.e Path.rootPath()) is used. """ - pieces = [name, str(path), value] + pieces = [name, str(path), self._encode(value)] return self.execute_command( "JSON.STRAPPEND", *pieces ) diff --git a/redis/commands/json/decoders.py b/redis/commands/json/decoders.py index 0ee102a433..b19395c73b 100644 --- a/redis/commands/json/decoders.py +++ b/redis/commands/json/decoders.py @@ -1,12 +1,59 @@ -def int_or_list(b): - if isinstance(b, int): - return b - else: - return b +from ..helpers import nativestr +import re +import copy + +def bulk_of_jsons(d): + """Replace serialized JSON values with objects in a + bulk array response (list). + """ -def int_or_none(b): - if b is None: - return None - if isinstance(b, int): + def _f(b): + for index, item in enumerate(b): + if item is not None: + b[index] = d(item) return b + + return _f + + +def decode_dict_keys(obj): + """Decode the keys of the given dictionary with utf-8.""" + newobj = copy.copy(obj) + for k in obj.keys(): + if isinstance(k, bytes): + newobj[k.decode("utf-8")] = newobj[k] + newobj.pop(k) + return newobj + + +def unstring(obj): + """ + Attempt to parse string to native integer formats. + One can't simply call int/float in a try/catch because there is a + semantic difference between (for example) 15.0 and 15. + """ + floatreg = '^\\d+.\\d+$' + match = re.findall(floatreg, obj) + if match != []: + return float(match[0]) + + intreg = "^\\d+$" + match = re.findall(intreg, obj) + if match != []: + return int(match[0]) + return obj + + +def decode_list(b): + """ + Given a non-deserializable object, make a best effort to + return a useful set of results. + """ + if isinstance(b, list): + return [nativestr(obj) for obj in b] + elif isinstance(b, bytes): + return unstring(nativestr(b)) + elif isinstance(b, str): + return unstring(b) + return b diff --git a/redis/commands/json/helpers.py b/redis/commands/json/helpers.py deleted file mode 100644 index 8fb20d9ac5..0000000000 --- a/redis/commands/json/helpers.py +++ /dev/null @@ -1,25 +0,0 @@ -import copy - - -def bulk_of_jsons(d): - """Replace serialized JSON values with objects in a - bulk array response (list). - """ - - def _f(b): - for index, item in enumerate(b): - if item is not None: - b[index] = d(item) - return b - - return _f - - -def decode_dict_keys(obj): - """Decode the keys of the given dictionary with utf-8.""" - newobj = copy.copy(obj) - for k in obj.keys(): - if isinstance(k, bytes): - newobj[k.decode("utf-8")] = newobj[k] - newobj.pop(k) - return newobj diff --git a/tests/conftest.py b/tests/conftest.py index 47188df07f..b1a0f8cab8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -126,7 +126,8 @@ def teardown(): @pytest.fixture() def modclient(request, **kwargs): rmurl = request.config.getoption('--redismod-url') - with _get_client(redis.Redis, request, from_url=rmurl, **kwargs) as client: + with _get_client(redis.Redis, request, from_url=rmurl, + decode_responses=True, **kwargs) as client: yield client diff --git a/tests/test_json.py b/tests/test_json.py index f62346f762..19b0c3262e 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1,6 +1,8 @@ import pytest import redis from redis.commands.json.path import Path +from redis import exceptions +from redis.commands.json.decoders import unstring, decode_list from .conftest import skip_ifmodversion_lt @@ -29,7 +31,7 @@ def test_json_setgetdeleteforget(client): @pytest.mark.redismod -def test_justaget(client): +def test_jsonget(client): client.json().set("foo", Path.rootPath(), "bar") assert client.json().get("foo") == "bar" @@ -45,9 +47,10 @@ def test_json_get_jset(client): @pytest.mark.redismod def test_nonascii_setgetdelete(client): - assert client.json().set("notascii", Path.rootPath(), - "hyvää-élève") is True - assert "hyvää-élève" == client.json().get("notascii", no_escape=True) + assert client.json().set("notascii", Path.rootPath(), "hyvää-élève") + assert "hyvää-élève" == client.json().get( + "notascii", + no_escape=True) assert 1 == client.json().delete("notascii") assert client.exists("notascii") == 0 @@ -90,8 +93,8 @@ def test_clear(client): @pytest.mark.redismod def test_type(client): client.json().set("1", Path.rootPath(), 1) - assert b"integer" == client.json().type("1", Path.rootPath()) - assert b"integer" == client.json().type("1") + assert "integer" == client.json().type("1", Path.rootPath()) + assert "integer" == client.json().type("1") @pytest.mark.redismod @@ -117,7 +120,7 @@ def test_nummultby(client): def test_toggle(client): client.json().set("bool", Path.rootPath(), False) assert client.json().toggle("bool", Path.rootPath()) - assert not client.json().toggle("bool", Path.rootPath()) + assert client.json().toggle("bool", Path.rootPath()) is False # check non-boolean value client.json().set("num", Path.rootPath(), 1) with pytest.raises(redis.exceptions.ResponseError): @@ -126,11 +129,8 @@ def test_toggle(client): @pytest.mark.redismod def test_strappend(client): - client.json().set("jsonkey", Path.rootPath(), 'foo') - import json - assert 6 == client.json().strappend("jsonkey", json.dumps('bar')) - with pytest.raises(redis.exceptions.ResponseError): - assert 6 == client.json().strappend("jsonkey", 'bar') + client.json().set("jsonkey", Path.rootPath(), "foo") + assert 6 == client.json().strappend("jsonkey", "bar") assert "foobar" == client.json().get("jsonkey", Path.rootPath()) @@ -148,8 +148,7 @@ def test_debug(client): def test_strlen(client): client.json().set("str", Path.rootPath(), "foo") assert 3 == client.json().strlen("str", Path.rootPath()) - import json - client.json().strappend("str", json.dumps("bar"), Path.rootPath()) + client.json().strappend("str", "bar", Path.rootPath()) assert 6 == client.json().strlen("str", Path.rootPath()) assert 6 == client.json().strlen("str") @@ -186,7 +185,7 @@ def test_arrinsert(client): # test prepends client.json().set("val2", Path.rootPath(), [5, 6, 7, 8, 9]) - client.json().arrinsert("val2", Path.rootPath(), 0, ['some', 'thing']) + client.json().arrinsert("val2", Path.rootPath(), 0, ["some", "thing"]) assert client.json().get("val2") == [["some", "thing"], 5, 6, 7, 8, 9] @@ -195,7 +194,7 @@ def test_arrlen(client): client.json().set("arr", Path.rootPath(), [0, 1, 2, 3, 4]) assert 5 == client.json().arrlen("arr", Path.rootPath()) assert 5 == client.json().arrlen("arr") - assert client.json().arrlen('fakekey') is None + assert client.json().arrlen("fakekey") is None @pytest.mark.redismod @@ -243,7 +242,7 @@ def test_arrtrim(client): def test_resp(client): obj = {"foo": "bar", "baz": 1, "qaz": True} client.json().set("obj", Path.rootPath(), obj) - assert b"bar" == client.json().resp("obj", Path("foo")) + assert "bar" == client.json().resp("obj", Path("foo")) assert 1 == client.json().resp("obj", Path("baz")) assert client.json().resp("obj", Path("qaz")) assert isinstance(client.json().resp("obj"), list) @@ -286,3 +285,1103 @@ def test_objlen(client): # assert [True, "bar", 1] == p.execute() # assert client.keys() == [] # assert client.get("foo") is None + + +@pytest.mark.redismod +def test_json_delete_with_dollar(client): + doc1 = {"a": 1, "nested": {"a": 2, "b": 3}} + assert client.json().set("doc1", "$", doc1) + assert client.json().delete("doc1", "$..a") == 2 + r = client.json().get("doc1", "$") + assert r == [{"nested": {"b": 3}}] + + doc2 = {"a": {"a": 2, "b": 3}, "b": [ + "a", "b"], "nested": {"b": [True, "a", "b"]}} + assert client.json().set("doc2", "$", doc2) + assert client.json().delete("doc2", "$..a") == 1 + res = client.json().get("doc2", "$") + assert res == [{"nested": {"b": [True, "a", "b"]}, "b": ["a", "b"]}] + + doc3 = [ + { + "ciao": ["non ancora"], + "nested": [ + {"ciao": [1, "a"]}, + {"ciao": [2, "a"]}, + {"ciaoc": [3, "non", "ciao"]}, + {"ciao": [4, "a"]}, + {"e": [5, "non", "ciao"]}, + ], + } + ] + assert client.json().set("doc3", "$", doc3) + assert client.json().delete("doc3", '$.[0]["nested"]..ciao') == 3 + + doc3val = [ + [ + { + "ciao": ["non ancora"], + "nested": [ + {}, + {}, + {"ciaoc": [3, "non", "ciao"]}, + {}, + {"e": [5, "non", "ciao"]}, + ], + } + ] + ] + res = client.json().get("doc3", "$") + assert res == doc3val + + # Test default path + assert client.json().delete("doc3") == 1 + assert client.json().get("doc3", "$") is None + + client.json().delete("not_a_document", "..a") + + +@pytest.mark.redismod +def test_json_forget_with_dollar(client): + doc1 = {"a": 1, "nested": {"a": 2, "b": 3}} + assert client.json().set("doc1", "$", doc1) + assert client.json().forget("doc1", "$..a") == 2 + r = client.json().get("doc1", "$") + assert r == [{"nested": {"b": 3}}] + + doc2 = {"a": {"a": 2, "b": 3}, "b": [ + "a", "b"], "nested": {"b": [True, "a", "b"]}} + assert client.json().set("doc2", "$", doc2) + assert client.json().forget("doc2", "$..a") == 1 + res = client.json().get("doc2", "$") + assert res == [{"nested": {"b": [True, "a", "b"]}, "b": ["a", "b"]}] + + doc3 = [ + { + "ciao": ["non ancora"], + "nested": [ + {"ciao": [1, "a"]}, + {"ciao": [2, "a"]}, + {"ciaoc": [3, "non", "ciao"]}, + {"ciao": [4, "a"]}, + {"e": [5, "non", "ciao"]}, + ], + } + ] + assert client.json().set("doc3", "$", doc3) + assert client.json().forget("doc3", '$.[0]["nested"]..ciao') == 3 + + doc3val = [ + [ + { + "ciao": ["non ancora"], + "nested": [ + {}, + {}, + {"ciaoc": [3, "non", "ciao"]}, + {}, + {"e": [5, "non", "ciao"]}, + ], + } + ] + ] + res = client.json().get("doc3", "$") + assert res == doc3val + + # Test default path + assert client.json().forget("doc3") == 1 + assert client.json().get("doc3", "$") is None + + client.json().forget("not_a_document", "..a") + + +@pytest.mark.redismod +def test_json_mget_dollar(client): + # Test mget with multi paths + client.json().set( + "doc1", + "$", + {"a": 1, + "b": 2, + "nested": {"a": 3}, + "c": None, "nested2": {"a": None}}, + ) + client.json().set( + "doc2", + "$", + {"a": 4, "b": 5, "nested": {"a": 6}, + "c": None, "nested2": {"a": [None]}}, + ) + # Compare also to single JSON.GET + assert client.json().get("doc1", "$..a") == [1, 3, None] + assert client.json().get("doc2", "$..a") == [4, 6, [None]] + + # Test mget with single path + 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]]] + + # 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] + + +@pytest.mark.redismod +def test_numby_commands_dollar(client): + + # Test NUMINCRBY + client.json().set( + "doc1", + "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) + # Test multi + assert client.json().numincrby("doc1", "$..a", 2) == \ + [None, 4, 7.0, None] + + assert client.json().numincrby("doc1", "$..a", 2.5) == \ + [None, 6.5, 9.5, None] + # Test single + assert client.json().numincrby("doc1", "$.b[1].a", 2) == [11.5] + + assert client.json().numincrby("doc1", "$.b[2].a", 2) == [None] + assert client.json().numincrby("doc1", "$.b[1].a", 3.5) == [15.0] + + # Test NUMMULTBY + client.json().set("doc1", "$", {"a": "b", "b": [ + {"a": 2}, {"a": 5.0}, {"a": "c"}]}) + + assert client.json().nummultby("doc1", "$..a", 2) == \ + [None, 4, 10, None] + assert client.json().nummultby("doc1", "$..a", 2.5) == \ + [None, 10.0, 25.0, None] + # Test single + assert client.json().nummultby("doc1", "$.b[1].a", 2) == [50.0] + assert client.json().nummultby("doc1", "$.b[2].a", 2) == [None] + assert client.json().nummultby("doc1", "$.b[1].a", 3) == [150.0] + + # test missing keys + with pytest.raises(exceptions.ResponseError): + client.json().numincrby("non_existing_doc", "$..a", 2) + client.json().nummultby("non_existing_doc", "$..a", 2) + + # 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 + + # Test legacy NUMMULTBY + client.json().set("doc1", "$", {"a": "b", "b": [ + {"a": 2}, {"a": 5.0}, {"a": "c"}]}) + client.json().nummultby("doc1", ".b[0].a", 3) == 6 + + +@pytest.mark.redismod +def test_strappend_dollar(client): + + client.json().set( + "doc1", "$", {"a": "foo", "nested1": { + "a": "hello"}, "nested2": {"a": 31}} + ) + # Test multi + client.json().strappend("doc1", "bar", "$..a") == [6, 8, None] + + client.json().get("doc1", "$") == [ + {"a": "foobar", "nested1": {"a": "hellobar"}, "nested2": {"a": 31}} + ] + # Test single + client.json().strappend("doc1", "baz", "$.nested1.a") == [11] + + client.json().get("doc1", "$") == [ + {"a": "foobar", "nested1": {"a": "hellobarbaz"}, "nested2": {"a": 31}} + ] + + # 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 + client.json().get("doc1", "$") == [ + {"a": "foo", "nested1": {"a": "hellobar"}, "nested2": {"a": 31}} + ] + + # Test missing path + with pytest.raises(exceptions.ResponseError): + client.json().strappend("doc1", "piu") + + +@pytest.mark.redismod +def test_strlen_dollar(client): + + # Test multi + client.json().set( + "doc1", "$", {"a": "foo", "nested1": { + "a": "hello"}, "nested2": {"a": 31}} + ) + assert client.json().strlen("doc1", "$..a") == [3, 5, None] + + res2 = client.json().strappend("doc1", "bar", "$..a") + res1 = client.json().strlen("doc1", "$..a") + assert res1 == res2 + + # Test single + client.json().strlen("doc1", "$.nested1.a") == [8] + client.json().strlen("doc1", "$.nested2.a") == [None] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().strlen("non_existing_doc", "$..a") + + +@pytest.mark.redismod +def test_arrappend_dollar(client): + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + # Test multi + client.json().arrappend("doc1", "$..a", "bar", "racuda") == [3, 5, None] + assert client.json().get("doc1", "$") == [ + { + "a": ["foo", "bar", "racuda"], + "nested1": {"a": ["hello", None, "world", "bar", "racuda"]}, + "nested2": {"a": 31}, + } + ] + + # Test single + assert client.json().arrappend("doc1", "$.nested1.a", "baz") == [6] + assert client.json().get("doc1", "$") == [ + { + "a": ["foo", "bar", "racuda"], + "nested1": {"a": ["hello", None, "world", "bar", "racuda", "baz"]}, + "nested2": {"a": 31}, + } + ] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().arrappend("non_existing_doc", "$..a") + + # Test legacy + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + # Test multi (all paths are updated, but return result of last path) + assert client.json().arrappend("doc1", "..a", "bar", "racuda") == 5 + + assert client.json().get("doc1", "$") == [ + { + "a": ["foo", "bar", "racuda"], + "nested1": {"a": ["hello", None, "world", "bar", "racuda"]}, + "nested2": {"a": 31}, + } + ] + # Test single + assert client.json().arrappend("doc1", ".nested1.a", "baz") == 6 + assert client.json().get("doc1", "$") == [ + { + "a": ["foo", "bar", "racuda"], + "nested1": {"a": ["hello", None, "world", "bar", "racuda", "baz"]}, + "nested2": {"a": 31}, + } + ] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().arrappend("non_existing_doc", "$..a") + + +@pytest.mark.redismod +def test_arrinsert_dollar(client): + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + # Test multi + assert client.json().arrinsert("doc1", "$..a", "1", + "bar", "racuda") == [3, 5, None] + + assert client.json().get("doc1", "$") == [ + { + "a": ["foo", "bar", "racuda"], + "nested1": {"a": ["hello", "bar", "racuda", None, "world"]}, + "nested2": {"a": 31}, + } + ] + # Test single + assert client.json().arrinsert("doc1", "$.nested1.a", -2, "baz") == [6] + assert client.json().get("doc1", "$") == [ + { + "a": ["foo", "bar", "racuda"], + "nested1": {"a": ["hello", "bar", "racuda", "baz", None, "world"]}, + "nested2": {"a": 31}, + } + ] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().arrappend("non_existing_doc", "$..a") + + +@pytest.mark.redismod +def test_arrlen_dollar(client): + + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + + # Test multi + assert client.json().arrlen("doc1", "$..a") == [1, 3, None] + assert client.json().arrappend("doc1", "$..a", "non", "abba", "stanza") \ + == [4, 6, None] + + client.json().clear("doc1", "$.a") + assert client.json().arrlen("doc1", "$..a") == [0, 6, None] + # Test single + assert client.json().arrlen("doc1", "$.nested1.a") == [6] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().arrappend("non_existing_doc", "$..a") + + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + # Test multi (return result of last path) + assert client.json().arrlen("doc1", "$..a") == [1, 3, None] + assert client.json().arrappend("doc1", "..a", "non", "abba", "stanza") == 6 + + # Test single + assert client.json().arrlen("doc1", ".nested1.a") == 6 + + # Test missing key + assert client.json().arrlen("non_existing_doc", "..a") is None + + +@pytest.mark.redismod +def test_arrpop_dollar(client): + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + + # # # Test multi + assert client.json().arrpop("doc1", "$..a", 1) == ['"foo"', None, None] + + assert client.json().get("doc1", "$") == [ + {"a": [], "nested1": {"a": ["hello", "world"]}, "nested2": {"a": 31}} + ] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().arrpop("non_existing_doc", "..a") + + # # Test legacy + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + # Test multi (all paths are updated, but return result of last path) + client.json().arrpop("doc1", "..a", "1") is None + assert client.json().get("doc1", "$") == [ + {"a": [], "nested1": {"a": ["hello", "world"]}, "nested2": {"a": 31}} + ] + + # # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().arrpop("non_existing_doc", "..a") + + +@pytest.mark.redismod +def test_arrtrim_dollar(client): + + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + # Test multi + assert client.json().arrtrim("doc1", "$..a", "1", -1) == [0, 2, None] + assert client.json().get("doc1", "$") == [ + {"a": [], "nested1": {"a": [None, "world"]}, "nested2": {"a": 31}} + ] + + assert client.json().arrtrim("doc1", "$..a", "1", "1") == [0, 1, None] + assert client.json().get("doc1", "$") == [ + {"a": [], "nested1": {"a": ["world"]}, "nested2": {"a": 31}} + ] + # Test single + assert client.json().arrtrim("doc1", "$.nested1.a", 1, 0) == [0] + assert client.json().get("doc1", "$") == [ + {"a": [], "nested1": {"a": []}, "nested2": {"a": 31}} + ] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().arrtrim("non_existing_doc", "..a", "0", 1) + + # Test legacy + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, + }, + ) + + # Test multi (all paths are updated, but return result of last path) + assert client.json().arrtrim("doc1", "..a", "1", "-1") == 2 + + # Test single + assert client.json().arrtrim("doc1", ".nested1.a", "1", "1") == 1 + assert client.json().get("doc1", "$") == [ + {"a": [], "nested1": {"a": ["world"]}, "nested2": {"a": 31}} + ] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().arrtrim("non_existing_doc", "..a", 1, 1) + + +@pytest.mark.redismod +def test_objkeys_dollar(client): + client.json().set( + "doc1", + "$", + { + "nested1": {"a": {"foo": 10, "bar": 20}}, + "a": ["foo"], + "nested2": {"a": {"baz": 50}}, + }, + ) + + # Test single + assert client.json().objkeys("doc1", "$.nested1.a") == [["foo", "bar"]] + + # Test legacy + assert client.json().objkeys("doc1", ".*.a") == ["foo", "bar"] + # Test single + assert client.json().objkeys("doc1", ".nested2.a") == ["baz"] + + # Test missing key + assert client.json().objkeys("non_existing_doc", "..a") is None + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().objkeys("doc1", "$.nowhere") + + +@pytest.mark.redismod +def test_objlen_dollar(client): + client.json().set( + "doc1", + "$", + { + "nested1": {"a": {"foo": 10, "bar": 20}}, + "a": ["foo"], + "nested2": {"a": {"baz": 50}}, + }, + ) + # Test multi + assert client.json().objlen("doc1", "$..a") == [2, None, 1] + # Test single + assert client.json().objlen("doc1", "$.nested1.a") == [2] + + # Test missing key + assert client.json().objlen("non_existing_doc", "$..a") is None + + # Test missing path + with pytest.raises(exceptions.ResponseError): + client.json().objlen("doc1", "$.nowhere") + + # Test legacy + assert client.json().objlen("doc1", ".*.a") == 2 + + # Test single + assert client.json().objlen("doc1", ".nested2.a") == 1 + + # Test missing key + assert client.json().objlen("non_existing_doc", "..a") is None + + # Test missing path + with pytest.raises(exceptions.ResponseError): + client.json().objlen("doc1", ".nowhere") + + +@pytest.mark.redismod +def load_types_data(nested_key_name): + td = { + "object": {}, + "array": [], + "string": "str", + "integer": 42, + "number": 1.2, + "boolean": False, + "null": None, + } + jdata = {} + types = [] + for i, (k, v) in zip(range(1, len(td) + 1), iter(td.items())): + jdata["nested" + str(i)] = {nested_key_name: v} + types.append(k) + + return jdata, types + + +@pytest.mark.redismod +def test_type_dollar(client): + jdata, jtypes = load_types_data("a") + client.json().set("doc1", "$", jdata) + # Test multi + assert client.json().type("doc1", "$..a") == jtypes + + # Test single + assert client.json().type("doc1", "$.nested2.a") == [jtypes[1]] + + # Test missing key + assert client.json().type("non_existing_doc", "..a") is None + + +@pytest.mark.redismod +def test_clear_dollar(client): + + client.json().set( + "doc1", + "$", + { + "nested1": {"a": {"foo": 10, "bar": 20}}, + "a": ["foo"], + "nested2": {"a": "claro"}, + "nested3": {"a": {"baz": 50}}, + }, + ) + # Test multi + assert client.json().clear("doc1", "$..a") == 3 + + assert client.json().get("doc1", "$") == [ + {"nested1": {"a": {}}, "a": [], "nested2": { + "a": "claro"}, "nested3": {"a": {}}} + ] + + # Test single + client.json().set( + "doc1", + "$", + { + "nested1": {"a": {"foo": 10, "bar": 20}}, + "a": ["foo"], + "nested2": {"a": "claro"}, + "nested3": {"a": {"baz": 50}}, + }, + ) + assert client.json().clear("doc1", "$.nested1.a") == 1 + assert client.json().get("doc1", "$") == [ + { + "nested1": {"a": {}}, + "a": ["foo"], + "nested2": {"a": "claro"}, + "nested3": {"a": {"baz": 50}}, + } + ] + + # Test missing path (defaults to root) + assert client.json().clear("doc1") == 1 + assert client.json().get("doc1", "$") == [{}] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().clear("non_existing_doc", "$..a") + + +@pytest.mark.redismod +def test_toggle_dollar(client): + client.json().set( + "doc1", + "$", + { + "a": ["foo"], + "nested1": {"a": False}, + "nested2": {"a": 31}, + "nested3": {"a": True}, + }, + ) + # Test multi + assert client.json().toggle("doc1", "$..a") == [None, 1, None, 0] + assert client.json().get("doc1", "$") == [ + { + "a": ["foo"], + "nested1": {"a": True}, + "nested2": {"a": 31}, + "nested3": {"a": False}, + } + ] + + # Test missing key + with pytest.raises(exceptions.ResponseError): + client.json().toggle("non_existing_doc", "$..a") + + +@pytest.mark.redismod +def test_debug_dollar(client): + + jdata, jtypes = load_types_data("a") + + client.json().set("doc1", "$", jdata) + + # Test multi + assert client.json().debug("MEMORY", "doc1", "$..a") == [ + 72, 24, 24, 16, 16, 1, 0] + + # Test single + assert client.json().debug("MEMORY", "doc1", "$.nested2.a") == [24] + + # Test legacy + assert client.json().debug("MEMORY", "doc1", "..a") == 72 + + # Test missing path (defaults to root) + assert client.json().debug("MEMORY", "doc1") == 72 + + # Test missing key + assert client.json().debug("MEMORY", "non_existing_doc", "$..a") == [] + + +def test_resp_dollar(client): + + data = { + "L1": { + "a": { + "A1_B1": 10, + "A1_B2": False, + "A1_B3": { + "A1_B3_C1": None, + "A1_B3_C2": [ + "A1_B3_C2_D1_1", + "A1_B3_C2_D1_2", + -19.5, + "A1_B3_C2_D1_4", + "A1_B3_C2_D1_5", + {"A1_B3_C2_D1_6_E1": True}, + ], + "A1_B3_C3": [1], + }, + "A1_B4": { + "A1_B4_C1": "foo", + }, + }, + }, + "L2": { + "a": { + "A2_B1": 20, + "A2_B2": False, + "A2_B3": { + "A2_B3_C1": None, + "A2_B3_C2": [ + "A2_B3_C2_D1_1", + "A2_B3_C2_D1_2", + -37.5, + "A2_B3_C2_D1_4", + "A2_B3_C2_D1_5", + {"A2_B3_C2_D1_6_E1": False}, + ], + "A2_B3_C3": [2], + }, + "A2_B4": { + "A2_B4_C1": "bar", + }, + }, + }, + } + client.json().set("doc1", "$", data) + # Test multi + res = client.json().resp("doc1", "$..a") + assert res == [ + [ + "{", + "A1_B1", + 10, + "A1_B2", + "false", + "A1_B3", + [ + "{", + "A1_B3_C1", + None, + "A1_B3_C2", + [ + "[", + "A1_B3_C2_D1_1", + "A1_B3_C2_D1_2", + "-19.5", + "A1_B3_C2_D1_4", + "A1_B3_C2_D1_5", + ["{", "A1_B3_C2_D1_6_E1", "true"], + ], + "A1_B3_C3", + ["[", 1], + ], + "A1_B4", + ["{", "A1_B4_C1", "foo"], + ], + [ + "{", + "A2_B1", + 20, + "A2_B2", + "false", + "A2_B3", + [ + "{", + "A2_B3_C1", + None, + "A2_B3_C2", + [ + "[", + "A2_B3_C2_D1_1", + "A2_B3_C2_D1_2", + "-37.5", + "A2_B3_C2_D1_4", + "A2_B3_C2_D1_5", + ["{", "A2_B3_C2_D1_6_E1", "false"], + ], + "A2_B3_C3", + ["[", 2], + ], + "A2_B4", + ["{", "A2_B4_C1", "bar"], + ], + ] + + # Test single + resSingle = client.json().resp("doc1", "$.L1.a") + assert resSingle == [ + [ + "{", + "A1_B1", + 10, + "A1_B2", + "false", + "A1_B3", + [ + "{", + "A1_B3_C1", + None, + "A1_B3_C2", + [ + "[", + "A1_B3_C2_D1_1", + "A1_B3_C2_D1_2", + "-19.5", + "A1_B3_C2_D1_4", + "A1_B3_C2_D1_5", + ["{", "A1_B3_C2_D1_6_E1", "true"], + ], + "A1_B3_C3", + ["[", 1], + ], + "A1_B4", + ["{", "A1_B4_C1", "foo"], + ] + ] + + # Test missing path + with pytest.raises(exceptions.ResponseError): + client.json().resp("doc1", "$.nowhere") + + # Test missing key + assert client.json().resp("non_existing_doc", "$..a") is None + + +def test_arrindex_dollar(client): + + client.json().set( + "store", + "$", + { + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95, + "size": [10, 20, 30, 40], + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99, + "size": [50, 60, 70, 80], + }, + { + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99, + "size": [5, 10, 20, 30], + }, + { + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99, + "size": [5, 6, 7, 8], + }, + ], + "bicycle": {"color": "red", "price": 19.95}, + } + }, + ) + + assert client.json().get("store", "$.store.book[?(@.price<10)].size") == [ + [10, 20, 30, 40], + [5, 10, 20, 30], + ] + assert client.json().arrindex( + "store", "$.store.book[?(@.price<10)].size", "20" + ) == [-1, -1] + + # Test index of int scalar in multi values + client.json().set( + "test_num", + ".", + [ + {"arr": [0, 1, 3.0, 3, 2, 1, 0, 3]}, + {"nested1_found": {"arr": [5, 4, 3, 2, 1, 0, 1, 2, 3.0, 2, 4, 5]}}, + {"nested2_not_found": {"arr": [2, 4, 6]}}, + {"nested3_scalar": {"arr": "3"}}, + [ + {"nested41_not_arr": {"arr_renamed": [1, 2, 3]}}, + {"nested42_empty_arr": {"arr": []}}, + ], + ], + ) + + assert client.json().get("test_num", "$..arr") == [ + [0, 1, 3.0, 3, 2, 1, 0, 3], + [5, 4, 3, 2, 1, 0, 1, 2, 3.0, 2, 4, 5], + [2, 4, 6], + "3", + [], + ] + + assert client.json().arrindex("test_num", "$..arr", 3) == [ + 3, 2, -1, None, -1] + + # Test index of double scalar in multi values + assert client.json().arrindex("test_num", "$..arr", 3.0) == [ + 2, 8, -1, None, -1] + + # Test index of string scalar in multi values + client.json().set( + "test_string", + ".", + [ + {"arr": ["bazzz", "bar", 2, "baz", 2, "ba", "baz", 3]}, + { + "nested1_found": { + "arr": [ + None, + "baz2", + "buzz", 2, 1, 0, 1, "2", "baz", 2, 4, 5] + } + }, + {"nested2_not_found": {"arr": ["baz2", 4, 6]}}, + {"nested3_scalar": {"arr": "3"}}, + [ + {"nested41_arr": {"arr_renamed": [1, "baz", 3]}}, + {"nested42_empty_arr": {"arr": []}}, + ], + ], + ) + assert client.json().get("test_string", "$..arr") == [ + ["bazzz", "bar", 2, "baz", 2, "ba", "baz", 3], + [None, "baz2", "buzz", 2, 1, 0, 1, "2", "baz", 2, 4, 5], + ["baz2", 4, 6], + "3", + [], + ] + + assert client.json().arrindex("test_string", "$..arr", "baz") == [ + 3, + 8, + -1, + None, + -1, + ] + + assert client.json().arrindex("test_string", "$..arr", "baz", 2) == [ + 3, + 8, + -1, + None, + -1, + ] + assert client.json().arrindex("test_string", "$..arr", "baz", 4) == [ + 6, + 8, + -1, + None, + -1, + ] + assert client.json().arrindex("test_string", "$..arr", "baz", -5) == [ + 3, + 8, + -1, + None, + -1, + ] + assert client.json().arrindex("test_string", "$..arr", "baz", 4, 7) == [ + 6, + -1, + -1, + None, + -1, + ] + assert client.json().arrindex("test_string", "$..arr", "baz", 4, -1) == [ + 6, + 8, + -1, + None, + -1, + ] + assert client.json().arrindex("test_string", "$..arr", "baz", 4, 0) == [ + 6, + 8, + -1, + None, + -1, + ] + assert client.json().arrindex("test_string", "$..arr", "5", 7, -1) == [ + -1, + -1, + -1, + None, + -1, + ] + assert client.json().arrindex("test_string", "$..arr", "5", 7, 0) == [ + -1, + -1, + -1, + None, + -1, + ] + + # Test index of None scalar in multi values + client.json().set( + "test_None", + ".", + [ + {"arr": ["bazzz", "None", 2, None, 2, "ba", "baz", 3]}, + { + "nested1_found": { + "arr": [ + "zaz", + "baz2", + "buzz", + 2, 1, 0, 1, "2", None, 2, 4, 5] + } + }, + {"nested2_not_found": {"arr": ["None", 4, 6]}}, + {"nested3_scalar": {"arr": None}}, + [ + {"nested41_arr": {"arr_renamed": [1, None, 3]}}, + {"nested42_empty_arr": {"arr": []}}, + ], + ], + ) + assert client.json().get("test_None", "$..arr") == [ + ["bazzz", "None", 2, None, 2, "ba", "baz", 3], + ["zaz", "baz2", "buzz", 2, 1, 0, 1, "2", None, 2, 4, 5], + ["None", 4, 6], + None, + [], + ] + + # Fail with none-scalar value + with pytest.raises(exceptions.ResponseError): + client.json().arrindex( + "test_None", "$..nested42_empty_arr.arr", {"arr": []}) + + # Do not fail with none-scalar value in legacy mode + assert ( + client.json().arrindex( + "test_None", ".[4][1].nested42_empty_arr.arr", '{"arr":[]}' + ) + == -1 + ) + + # Test legacy (path begins with dot) + # Test index of int scalar in single value + assert client.json().arrindex("test_num", ".[0].arr", 3) == 3 + assert client.json().arrindex("test_num", ".[0].arr", 9) == -1 + + with pytest.raises(exceptions.ResponseError): + client.json().arrindex("test_num", ".[0].arr_not", 3) + # Test index of string scalar in single value + assert client.json().arrindex("test_string", ".[0].arr", "baz") == 3 + assert client.json().arrindex("test_string", ".[0].arr", "faz") == -1 + # Test index of None scalar in single value + assert client.json().arrindex("test_None", ".[0].arr", "None") == 1 + assert client.json().arrindex( + "test_None", + "..nested2_not_found.arr", + "None") == 0 + + +def test_decoders_and_unstring(): + assert unstring("4") == 4 + assert unstring("45.55") == 45.55 + assert unstring("hello world") == "hello world" + + assert decode_list(b"45.55") == 45.55 + assert decode_list("45.55") == 45.55 + assert decode_list(['hello', b'world']) == ['hello', 'world'] diff --git a/tests/testdata/jsontestdata.py b/tests/testdata/jsontestdata.py new file mode 100644 index 0000000000..0a920cc55b --- /dev/null +++ b/tests/testdata/jsontestdata.py @@ -0,0 +1,617 @@ +nested_large_key = r""" +{ + "jkra": [ + 154, + 4472, + [ + 8567, + false, + 363.84, + 5276, + "ha", + "rizkzs", + 93 + ], + false + ], + "hh": 20.77, + "mr": 973.217, + "ihbe": [ + 68, + [ + true, + { + "lqe": [ + 486.363, + [ + true, + { + "mp": { + "ory": "rj", + "qnl": "tyfrju", + "hf": None + }, + "uooc": 7418, + "xela": 20, + "bt": 7014, + "ia": 547, + "szec": 68.73 + }, + None + ], + 3622, + "iwk", + None + ], + "fepi": 19.954, + "ivu": { + "rmnd": 65.539, + "bk": 98, + "nc": "bdg", + "dlb": { + "hw": { + "upzz": [ + true, + { + "nwb": [ + 4259.47 + ], + "nbt": "yl" + }, + false, + false, + 65, + [ + [ + [], + 629.149, + "lvynqh", + "hsk", + [], + 2011.932, + true, + [] + ], + None, + "ymbc", + None + ], + "aj", + 97.425, + "hc", + 58 + ] + }, + "jq": true, + "bi": 3333, + "hmf": "pl", + "mrbj": [ + true, + false + ] + } + }, + "hfj": "lwk", + "utdl": "aku", + "alqb": [ + 74, + 534.389, + 7235, + [ + None, + false, + None + ] + ] + }, + None, + { + "lbrx": { + "vm": "ubdrbb" + }, + "tie": "iok", + "br": "ojro" + }, + 70.558, + [ + { + "mmo": None, + "dryu": None + } + ] + ], + true, + None, + false, + { + "jqun": 98, + "ivhq": [ + [ + [ + 675.936, + [ + 520.15, + 1587.4, + false + ], + "jt", + true, + { + "bn": None, + "ygn": "cve", + "zhh": true, + "aak": 9165, + "skx": true, + "qqsk": 662.28 + }, + { + "eio": 9933.6, + "agl": None, + "pf": false, + "kv": 5099.631, + "no": None, + "shly": 58 + }, + [ + None, + [ + "uiundu", + 726.652, + false, + 94.92, + 259.62, + { + "ntqu": None, + "frv": None, + "rvop": "upefj", + "jvdp": { + "nhx": [], + "bxnu": {}, + "gs": None, + "mqho": None, + "xp": 65, + "ujj": {} + }, + "ts": false, + "kyuk": [ + false, + 58, + {}, + "khqqif" + ] + }, + 167, + true, + "bhlej", + 53 + ], + 64, + { + "eans": "wgzfo", + "zfgb": 431.67, + "udy": [ + { + "gnt": [], + "zeve": {} + }, + { + "pg": {}, + "vsuc": {}, + "dw": 19, + "ffo": "uwsh", + "spk": "pjdyam", + "mc": [], + "wunb": {}, + "qcze": 2271.15, + "mcqx": None + }, + "qob" + ], + "wo": "zy" + }, + { + "dok": None, + "ygk": None, + "afdw": [ + 7848, + "ah", + None + ], + "foobar": 3.141592, + "wnuo": { + "zpvi": { + "stw": true, + "bq": {}, + "zord": true, + "omne": 3061.73, + "bnwm": "wuuyy", + "tuv": 7053, + "lepv": None, + "xap": 94.26 + }, + "nuv": false, + "hhza": 539.615, + "rqw": { + "dk": 2305, + "wibo": 7512.9, + "ytbc": 153, + "pokp": None, + "whzd": None, + "judg": [], + "zh": None + }, + "bcnu": "ji", + "yhqu": None, + "gwc": true, + "smp": { + "fxpl": 75, + "gc": [], + "vx": 9352.895, + "fbzf": 4138.27, + "tiaq": 354.306, + "kmfb": {}, + "fxhy": [], + "af": 94.46, + "wg": {}, + "fb": None + } + }, + "zvym": 2921, + "hhlh": [ + 45, + 214.345 + ], + "vv": "gqjoz" + }, + [ + "uxlu", + None, + "utl", + 64, + [ + 2695 + ], + [ + false, + None, + [ + "cfcrl", + [], + [], + 562, + 1654.9, + {}, + None, + "sqzud", + 934.6 + ], + { + "hk": true, + "ed": "lodube", + "ye": "ziwddj", + "ps": None, + "ir": {}, + "heh": false + }, + true, + 719, + 50.56, + [ + 99, + 6409, + None, + 4886, + "esdtkt", + {}, + None + ], + [ + false, + "bkzqw" + ] + ], + None, + 6357 + ], + { + "asvv": 22.873, + "vqm": { + "drmv": 68.12, + "tmf": 140.495, + "le": None, + "sanf": [ + true, + [], + "vyawd", + false, + 76.496, + [], + "sdfpr", + 33.16, + "nrxy", + "antje" + ], + "yrkh": 662.426, + "vxj": true, + "sn": 314.382, + "eorg": None + }, + "bavq": [ + 21.18, + 8742.66, + { + "eq": "urnd" + }, + 56.63, + "fw", + [ + {}, + "pjtr", + None, + "apyemk", + [], + [], + false, + {} + ], + { + "ho": None, + "ir": 124, + "oevp": 159, + "xdrv": 6705, + "ff": [], + "sx": false + }, + true, + None, + true + ], + "zw": "qjqaap", + "hr": { + "xz": 32, + "mj": 8235.32, + "yrtv": None, + "jcz": "vnemxe", + "ywai": [ + None, + 564, + false, + "vbr", + 54.741 + ], + "vw": 82, + "wn": true, + "pav": true + }, + "vxa": 881 + }, + "bgt", + "vuzk", + 857 + ] + ] + ], + None, + None, + { + "xyzl": "nvfff" + }, + true, + 13 + ], + "npd": None, + "ha": [ + [ + "du", + [ + 980, + { + "zdhd": [ + 129.986, + [ + "liehns", + 453, + { + "fuq": false, + "dxpn": {}, + "hmpx": 49, + "zb": "gbpt", + "vdqc": None, + "ysjg": false, + "gug": 7990.66 + }, + "evek", + [ + {} + ], + "dfywcu", + 9686, + None + ] + ], + "gpi": { + "gt": { + "qe": 7460, + "nh": "nrn", + "czj": 66.609, + "jwd": true, + "rb": "azwwe", + "fj": { + "csn": true, + "foobar": 1.61803398875, + "hm": "efsgw", + "zn": "vbpizt", + "tjo": 138.15, + "teo": {}, + "hecf": [], + "ls": false + } + }, + "xlc": 7916, + "jqst": 48.166, + "zj": "ivctu" + }, + "jl": 369.27, + "mxkx": None, + "sh": [ + true, + 373, + false, + "sdis", + 6217, + { + "ernm": None, + "srbo": 90.798, + "py": 677, + "jgrq": None, + "zujl": None, + "odsm": { + "pfrd": None, + "kwz": "kfvjzb", + "ptkp": false, + "pu": None, + "xty": None, + "ntx": [], + "nq": 48.19, + "lpyx": [] + }, + "ff": None, + "rvi": [ + "ych", + {}, + 72, + 9379, + 7897.383, + true, + {}, + 999.751, + false + ] + }, + true + ], + "ghe": [ + 24, + { + "lpr": true, + "qrs": true + }, + true, + false, + 7951.94, + true, + 2690.54, + [ + 93, + None, + None, + "rlz", + true, + "ky", + true + ] + ], + "vet": false, + "olle": None + }, + "jzm", + true + ], + None, + None, + 19.17, + 7145, + "ipsmk" + ], + false, + { + "du": 6550.959, + "sps": 8783.62, + "nblr": { + "dko": 9856.616, + "lz": { + "phng": "dj" + }, + "zeu": 766, + "tn": "dkr" + }, + "xa": "trdw", + "gn": 9875.687, + "dl": None, + "vuql": None + }, + { + "qpjo": None, + "das": { + "or": { + "xfy": None, + "xwvs": 4181.86, + "yj": 206.325, + "bsr": [ + "qrtsh" + ], + "wndm": { + "ve": 56, + "jyqa": true, + "ca": None + }, + "rpd": 9906, + "ea": "dvzcyt" + }, + "xwnn": 9272, + "rpx": "zpr", + "srzg": { + "beo": 325.6, + "sq": None, + "yf": None, + "nu": [ + 377, + "qda", + true + ], + "sfz": "zjk" + }, + "kh": "xnpj", + "rk": None, + "hzhn": [ + None + ], + "uio": 6249.12, + "nxrv": 1931.635, + "pd": None + }, + "pxlc": true, + "mjer": false, + "hdev": "msr", + "er": None + }, + "ug", + None, + "yrfoix", + 503.89, + 563 + ], + "tcy": 300, + "me": 459.17, + "tm": [ + 134.761, + "jcoels", + None + ], + "iig": 945.57, + "ad": "be" + }, + "ltpdm", + None, + 14.53 + ], + "xi": "gxzzs", + "zfpw": 1564.87, + "ow": None, + "tm": [ + 46, + 876.85 + ], + "xejv": None +} +""" # noqa From 9804bdc52bdf4e85e66ae11246453be5dc40a9c0 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 4 Nov 2021 13:55:33 +0200 Subject: [PATCH 0227/1164] publish to pypi as releases are generated with the release drafter (#1647) --- .github/workflows/pypi-publish.yaml | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/pypi-publish.yaml diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml new file mode 100644 index 0000000000..b842c36670 --- /dev/null +++ b/.github/workflows/pypi-publish.yaml @@ -0,0 +1,31 @@ +name: Publish tag to Pypi + +on: + release: + types: [published] + +jobs: + + build_and_package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: install python + uses: actions/setup-python@v2 + with: + python-version: 3.0 + - name: Install dev tools + run: | + pip install -r dev_requirements.txt + pip install twine wheel + + - name: Build package + run: | + python setup.py build + python setup.py dist bdist_wheel + + - name: Publish to Pypi + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} From 4257ceb1e5b438d9e7ea4d2ac0c74609c2771749 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 4 Nov 2021 14:16:06 +0200 Subject: [PATCH 0228/1164] rc1 (#1668) --- redis/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/__init__.py b/redis/__init__.py index 2458b5bc49..003f8a2ecf 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -31,7 +31,7 @@ def int_or_str(value): return value -__version__ = '4.0.0b3' +__version__ = '4.0.0rc1' VERSION = tuple(map(int_or_str, __version__.split('.'))) __all__ = [ From bba75187931af84dd21c91bcf1b3bd422c9aed72 Mon Sep 17 00:00:00 2001 From: Eugene Morozov Date: Mon, 8 Nov 2021 09:59:14 +0300 Subject: [PATCH 0229/1164] Fix garbage collection deadlock (#1578) --- dev_requirements.txt | 1 + redis/connection.py | 9 ++++++--- tests/test_pubsub.py | 12 ++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/dev_requirements.txt b/dev_requirements.txt index aa9d8f9eee..0ca7727049 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,5 +1,6 @@ flake8>=3.9.2 pytest==6.2.5 +pytest-timeout==2.0.1 tox==3.24.4 tox-docker==3.1.0 invoke==1.6.0 diff --git a/redis/connection.py b/redis/connection.py index f5d6a38221..cb9acb4595 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -10,6 +10,7 @@ import socket import threading import warnings +import weakref from redis.exceptions import ( AuthenticationError, @@ -562,7 +563,7 @@ def __del__(self): pass def register_connect_callback(self, callback): - self._connect_callbacks.append(callback) + self._connect_callbacks.append(weakref.WeakMethod(callback)) def clear_connect_callbacks(self): self._connect_callbacks = [] @@ -588,8 +589,10 @@ def connect(self): # run any user callbacks. right now the only internal callback # is for pubsub channel/pattern resubscription - for callback in self._connect_callbacks: - callback(self) + for ref in self._connect_callbacks: + callback = ref() + if callback: + callback(self) def _connect(self): "Create a TCP socket connection" diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 6a4f0aafa4..4be6c7a305 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -570,3 +570,15 @@ def exception_handler(ex, pubsub, thread): assert event.wait(timeout=1.0) pubsub_thread.join(timeout=1.0) assert not pubsub_thread.is_alive() + + +class TestPubSubDeadlock: + @pytest.mark.timeout(30, method='thread') + def test_pubsub_deadlock(self, master_host): + pool = redis.ConnectionPool(host=master_host) + r = redis.Redis(connection_pool=pool) + + for i in range(60): + p = r.pubsub() + p.subscribe("my-channel-1", "my-channel-2") + pool.reset() From ea04bae5e082ff71aaa1f9a9d07d9bda10b7696e Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Mon, 8 Nov 2021 16:51:54 +0100 Subject: [PATCH 0230/1164] Restore zrange functionality for older versions of Redis (#1670) --- redis/commands/core.py | 62 ++++++++++++++++++++++++++++++++---------- tests/test_commands.py | 2 +- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index 8dba5a2abf..90997ff931 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -2536,9 +2536,14 @@ def zrevrange(self, name, start, end, withscores=False, ``score_cast_func`` a callable used to cast the score return value """ - return self.zrange(name, start, end, desc=True, - withscores=withscores, - score_cast_func=score_cast_func) + pieces = ['ZREVRANGE', name, start, end] + if withscores: + pieces.append(b'WITHSCORES') + options = { + 'withscores': withscores, + 'score_cast_func': score_cast_func + } + return self.execute_command(*pieces, **options) def zrangestore(self, dest, name, start, end, byscore=False, bylex=False, desc=False, @@ -2575,7 +2580,13 @@ def zrangebylex(self, name, min, max, start=None, num=None): If ``start`` and ``num`` are specified, then return a slice of the range. """ - return self.zrange(name, min, max, bylex=True, offset=start, num=num) + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + pieces = ['ZRANGEBYLEX', name, min, max] + if start is not None and num is not None: + pieces.extend([b'LIMIT', start, num]) + return self.execute_command(*pieces) def zrevrangebylex(self, name, max, min, start=None, num=None): """ @@ -2585,8 +2596,13 @@ def zrevrangebylex(self, name, max, min, start=None, num=None): If ``start`` and ``num`` are specified, then return a slice of the range. """ - return self.zrange(name, max, min, desc=True, - bylex=True, offset=start, num=num) + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + pieces = ['ZREVRANGEBYLEX', name, max, min] + if start is not None and num is not None: + pieces.extend(['LIMIT', start, num]) + return self.execute_command(*pieces) def zrangebyscore(self, name, min, max, start=None, num=None, withscores=False, score_cast_func=float): @@ -2602,10 +2618,19 @@ def zrangebyscore(self, name, min, max, start=None, num=None, `score_cast_func`` a callable used to cast the score return value """ - return self.zrange(name, min, max, byscore=True, - offset=start, num=num, - withscores=withscores, - score_cast_func=score_cast_func) + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + pieces = ['ZRANGEBYSCORE', name, min, max] + if start is not None and num is not None: + pieces.extend(['LIMIT', start, num]) + if withscores: + pieces.append('WITHSCORES') + options = { + 'withscores': withscores, + 'score_cast_func': score_cast_func + } + return self.execute_command(*pieces, **options) def zrevrangebyscore(self, name, max, min, start=None, num=None, withscores=False, score_cast_func=float): @@ -2621,10 +2646,19 @@ def zrevrangebyscore(self, name, max, min, start=None, num=None, ``score_cast_func`` a callable used to cast the score return value """ - return self.zrange(name, max, min, desc=True, - byscore=True, offset=start, - num=num, withscores=withscores, - score_cast_func=score_cast_func) + if (start is not None and num is None) or \ + (num is not None and start is None): + raise DataError("``start`` and ``num`` must both be specified") + pieces = ['ZREVRANGEBYSCORE', name, max, min] + if start is not None and num is not None: + pieces.extend(['LIMIT', start, num]) + if withscores: + pieces.append('WITHSCORES') + options = { + 'withscores': withscores, + 'score_cast_func': score_cast_func + } + return self.execute_command(*pieces, **options) def zrank(self, name, value): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index 723612af6b..37a369851b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1866,7 +1866,7 @@ def test_zrange(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) assert r.zrange('a', 0, 1) == [b'a1', b'a2'] assert r.zrange('a', 1, 2) == [b'a2', b'a3'] - assert r.zrange('a', 0, 2, desc=True) == [b'a3', b'a2', b'a1'] + assert r.zrange('a', 0, 2) == [b'a1', b'a2', b'a3'] # withscores assert r.zrange('a', 0, 1, withscores=True) == \ From 325fcd9e15bf0125f9a60012d7eb7824e7b4ab33 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Mon, 8 Nov 2021 16:52:32 +0100 Subject: [PATCH 0231/1164] Fix georadius tests (#1672) --- tests/test_commands.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 37a369851b..6d4ab008ab 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -2744,7 +2744,7 @@ def test_georadius_with(self, r): assert r.georadius('barcelona', 2, 1, 1, unit='km', withdist=True, withcoord=True, withhash=True) == [] - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt('6.2.0') def test_georadius_count(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ (2.1873744593677, 41.406342043777, 'place2') @@ -2806,6 +2806,12 @@ def test_georadiusmember(self, r): (2.187376320362091, 41.40634178640635)], [b'place1', 0.0, 3471609698139488, (2.1909382939338684, 41.433790281840835)]] + + @skip_if_server_version_lt('6.2.0') + def test_georadiusmember_count(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, b'\x80place2') + r.geoadd('barcelona', values) assert r.georadiusbymember('barcelona', 'place1', 4000, count=1, any=True) == \ [b'\x80place2'] From fea7b85dde375a228f485d27737de66592b28848 Mon Sep 17 00:00:00 2001 From: Terence Honles Date: Mon, 8 Nov 2021 16:54:24 +0100 Subject: [PATCH 0232/1164] Export Sentinel, and SSL like other classes (#1671) --- README.md | 14 +++++++++++++- redis/__init__.py | 10 ++++++++++ redis/sentinel.py | 4 +++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a01f820326..f03053e691 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ Connecting redis-py to the Sentinel instance(s) is easy. You can use a Sentinel connection to discover the master and slaves network addresses: ``` pycon ->>> from redis.sentinel import Sentinel +>>> from redis import Sentinel >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) >>> sentinel.discover_master('mymaster') ('127.0.0.1', 6379) @@ -373,6 +373,18 @@ Sentinel connection to discover the master and slaves network addresses: [('127.0.0.1', 6380)] ``` +To connect to a sentinel which uses SSL ([see SSL +connections](#ssl-connections) for more examples of SSL configurations): + +``` pycon +>>> from redis import Sentinel +>>> sentinel = Sentinel([('localhost', 26379)], + ssl=True, + ssl_ca_certs='/etc/ssl/certs/ca-certificates.crt') +>>> sentinel.discover_master('mymaster') +('127.0.0.1', 6379) +``` + You can also create Redis client connections from a Sentinel instance. You can connect to either the master (for write operations) or a slave (for read-only operations). diff --git a/redis/__init__.py b/redis/__init__.py index 003f8a2ecf..603f8464a2 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -6,6 +6,12 @@ SSLConnection, UnixDomainSocketConnection ) +from redis.sentinel import ( + Sentinel, + SentinelConnectionPool, + SentinelManagedConnection, + SentinelManagedSSLConnection, +) from redis.utils import from_url from redis.exceptions import ( AuthenticationError, @@ -51,6 +57,10 @@ def int_or_str(value): 'Redis', 'RedisError', 'ResponseError', + 'Sentinel', + 'SentinelConnectionPool', + 'SentinelManagedConnection', + 'SentinelManagedSSLConnection', 'SSLConnection', 'StrictRedis', 'TimeoutError', diff --git a/redis/sentinel.py b/redis/sentinel.py index 740d92f983..17dd75bf46 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -80,7 +80,9 @@ class SentinelConnectionPool(ConnectionPool): def __init__(self, service_name, sentinel_manager, **kwargs): kwargs['connection_class'] = kwargs.get( - 'connection_class', SentinelManagedConnection) + 'connection_class', + SentinelManagedSSLConnection if kwargs.pop('ssl', False) + else SentinelManagedConnection) self.is_master = kwargs.pop('is_master', True) self.check_connection = kwargs.pop('check_connection', False) super().__init__(**kwargs) From 599f5a991eda770799a1aea23848b1442019deeb Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 9 Nov 2021 11:24:48 +0200 Subject: [PATCH 0233/1164] Re-enable pipeline support for JSON and TimeSeries (#1674) --- redis/commands/helpers.py | 32 +++++++++++++++++++++ redis/commands/json/__init__.py | 27 ++++++++++++++++++ redis/commands/json/commands.py | 18 ++++++++++++ redis/commands/timeseries/__init__.py | 41 +++++++++++++++++++++------ tests/test_json.py | 32 ++++++++++++++------- tests/test_timeseries.py | 31 +++++++++----------- tox.ini | 11 +++++-- 7 files changed, 154 insertions(+), 38 deletions(-) diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index 2a4298cb2f..22cb6223b9 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -1,3 +1,7 @@ +import random +import string + + def list_or_args(keys, args): # returns a single new list combining keys and args try: @@ -42,3 +46,31 @@ def parse_to_list(response): except TypeError: res.append(None) return res + + +def random_string(length=10): + """ + Returns a random N character long string. + """ + return "".join( # nosec + random.choice(string.ascii_lowercase) for x in range(length) + ) + + +def quote_string(v): + """ + RedisGraph strings must be quoted, + quote_string wraps given v with quotes incase + v is a string. + """ + + if isinstance(v, bytes): + v = v.decode() + elif not isinstance(v, str): + return v + if len(v) == 0: + return '""' + + v = v.replace('"', '\\"') + + return '"{}"'.format(v) diff --git a/redis/commands/json/__init__.py b/redis/commands/json/__init__.py index d00627efce..d634dbd3f4 100644 --- a/redis/commands/json/__init__.py +++ b/redis/commands/json/__init__.py @@ -6,6 +6,7 @@ ) from ..helpers import nativestr from .commands import JSONCommands +import redis class JSON(JSONCommands): @@ -91,3 +92,29 @@ def _decode(self, obj): def _encode(self, obj): """Get the encoder.""" return self.__encoder__.encode(obj) + + def pipeline(self, transaction=True, shard_hint=None): + """Creates a pipeline for the JSON module, that can be used for executing + JSON commands, as well as classic core commands. + + Usage example: + + r = redis.Redis() + pipe = r.json().pipeline() + pipe.jsonset('foo', '.', {'hello!': 'world'}) + pipe.jsonget('foo') + pipe.jsonget('notakey') + """ + p = Pipeline( + connection_pool=self.client.connection_pool, + response_callbacks=self.MODULE_CALLBACKS, + transaction=transaction, + shard_hint=shard_hint, + ) + p._encode = self._encode + p._decode = self._decode + return p + + +class Pipeline(JSONCommands, redis.client.Pipeline): + """Pipeline for the module.""" diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py index 716741c19b..4436f6aa34 100644 --- a/redis/commands/json/commands.py +++ b/redis/commands/json/commands.py @@ -154,6 +154,9 @@ def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): ``xx`` if set to True, set ``value`` only if it exists. ``decode_keys`` If set to True, the keys of ``obj`` will be decoded with utf-8. + + For the purpose of using this within a pipeline, this command is also + aliased to jsonset. """ if decode_keys: obj = decode_dict_keys(obj) @@ -212,3 +215,18 @@ def debug(self, subcommand, key=None, path=Path.rootPath()): pieces.append(key) pieces.append(str(path)) return self.execute_command("JSON.DEBUG", *pieces) + + @deprecated(version='4.0.0', + reason='redisjson-py supported this, call get directly.') + def jsonget(self, *args, **kwargs): + return self.get(*args, **kwargs) + + @deprecated(version='4.0.0', + reason='redisjson-py supported this, call get directly.') + def jsonmget(self, *args, **kwargs): + return self.mget(*args, **kwargs) + + @deprecated(version='4.0.0', + reason='redisjson-py supported this, call get directly.') + def jsonset(self, *args, **kwargs): + return self.set(*args, **kwargs) diff --git a/redis/commands/timeseries/__init__.py b/redis/commands/timeseries/__init__.py index db9c3a55d4..83fa17082e 100644 --- a/redis/commands/timeseries/__init__.py +++ b/redis/commands/timeseries/__init__.py @@ -1,4 +1,4 @@ -from redis.client import bool_ok +import redis.client from .utils import ( parse_range, @@ -37,12 +37,12 @@ class TimeSeries(TimeSeriesCommands): def __init__(self, client=None, version=None, **kwargs): """Create a new RedisTimeSeries client.""" # Set the module commands' callbacks - MODULE_CALLBACKS = { - CREATE_CMD: bool_ok, - ALTER_CMD: bool_ok, - CREATERULE_CMD: bool_ok, + self.MODULE_CALLBACKS = { + CREATE_CMD: redis.client.bool_ok, + ALTER_CMD: redis.client.bool_ok, + CREATERULE_CMD: redis.client.bool_ok, DEL_CMD: int, - DELETERULE_CMD: bool_ok, + DELETERULE_CMD: redis.client.bool_ok, RANGE_CMD: parse_range, REVRANGE_CMD: parse_range, MRANGE_CMD: parse_m_range, @@ -57,5 +57,30 @@ def __init__(self, client=None, version=None, **kwargs): self.execute_command = client.execute_command self.MODULE_VERSION = version - for k in MODULE_CALLBACKS: - self.client.set_response_callback(k, MODULE_CALLBACKS[k]) + for key, value in self.MODULE_CALLBACKS.items(): + self.client.set_response_callback(key, value) + + def pipeline(self, transaction=True, shard_hint=None): + """Creates a pipeline for the TimeSeries module, that can be used + for executing only TimeSeries commands and core commands. + + Usage example: + + r = redis.Redis() + pipe = r.ts().pipeline() + for i in range(100): + pipeline.add("with_pipeline", i, 1.1 * i) + pipeline.execute() + + """ + p = Pipeline( + connection_pool=self.client.connection_pool, + response_callbacks=self.MODULE_CALLBACKS, + transaction=transaction, + shard_hint=shard_hint, + ) + return p + + +class Pipeline(TimeSeriesCommands, redis.client.Pipeline): + """Pipeline for the module.""" diff --git a/tests/test_json.py b/tests/test_json.py index 19b0c3262e..b3f38f7ff2 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -275,16 +275,28 @@ def test_objlen(client): assert len(obj) == client.json().objlen("obj") -# @pytest.mark.pipeline -# @pytest.mark.redismod -# def test_pipelineshouldsucceed(client): -# p = client.json().pipeline() -# p.set("foo", Path.rootPath(), "bar") -# p.get("foo") -# p.delete("foo") -# assert [True, "bar", 1] == p.execute() -# assert client.keys() == [] -# assert client.get("foo") is None +@pytest.mark.pipeline +@pytest.mark.redismod +def test_json_commands_in_pipeline(client): + p = client.json().pipeline() + p.set("foo", Path.rootPath(), "bar") + p.get("foo") + p.delete("foo") + assert [True, "bar", 1] == p.execute() + assert client.keys() == [] + assert client.get("foo") is None + + # now with a true, json object + client.flushdb() + p = client.json().pipeline() + d = {"hello": "world", "oh": "snap"} + p.jsonset("foo", Path.rootPath(), d) + p.jsonget("foo") + p.exists("notarealkey") + p.delete("foo") + assert [True, d, 0, 1] == p.execute() + assert client.keys() == [] + assert client.get("foo") is None @pytest.mark.redismod diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index b2df3feda5..d3d474ff6f 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -49,10 +49,6 @@ def testAlter(client): assert 10 == client.ts().info(1).retention_msecs -# pipe = client.ts().pipeline() -# assert pipe.create(2) - - @pytest.mark.redismod @skip_ifmodversion_lt("1.4.0", "timeseries") def testAlterDiplicatePolicy(client): @@ -568,20 +564,19 @@ def testQueryIndex(client): assert [2] == client.ts().queryindex(["Taste=That"]) -# -# @pytest.mark.redismod -# @pytest.mark.pipeline -# def testPipeline(client): -# pipeline = client.ts().pipeline() -# pipeline.create("with_pipeline") -# for i in range(100): -# pipeline.add("with_pipeline", i, 1.1 * i) -# pipeline.execute() - -# info = client.ts().info("with_pipeline") -# assert info.lastTimeStamp == 99 -# assert info.total_samples == 100 -# assert client.ts().get("with_pipeline")[1] == 99 * 1.1 +@pytest.mark.redismod +@pytest.mark.pipeline +def test_pipeline(client): + pipeline = client.ts().pipeline() + pipeline.create("with_pipeline") + for i in range(100): + pipeline.add("with_pipeline", i, 1.1 * i) + pipeline.execute() + + info = client.ts().info("with_pipeline") + assert info.lastTimeStamp == 99 + assert info.total_samples == 100 + assert client.ts().get("with_pipeline")[1] == 99 * 1.1 @pytest.mark.redismod diff --git a/tox.ini b/tox.ini index f09b3a831c..1d4da0829b 100644 --- a/tox.ini +++ b/tox.ini @@ -121,6 +121,13 @@ basepython = pypy3 [flake8] exclude = - .venv, + *.egg-info, + *.pyc, + .git, .tox, - whitelist.py + .venv*, + build, + dist, + docker, + venv*, + whitelist.py \ No newline at end of file From 2a52f3f84554f645c5d532e6043c46418fada5e4 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 9 Nov 2021 12:17:09 +0200 Subject: [PATCH 0234/1164] Removing dependency on six (#1676) --- redis/commands/search/_util.py | 7 ++----- redis/commands/search/aggregation.py | 8 +++----- redis/commands/search/commands.py | 14 ++++++-------- redis/commands/search/document.py | 5 +---- redis/commands/search/query.py | 5 +---- redis/commands/search/querystring.py | 5 +---- redis/commands/search/result.py | 6 ++---- redis/commands/search/suggestion.py | 3 +-- 8 files changed, 17 insertions(+), 36 deletions(-) diff --git a/redis/commands/search/_util.py b/redis/commands/search/_util.py index b4ac19f336..dd1dff33dd 100644 --- a/redis/commands/search/_util.py +++ b/redis/commands/search/_util.py @@ -1,10 +1,7 @@ -import six - - def to_string(s): - if isinstance(s, six.string_types): + if isinstance(s, str): return s - elif isinstance(s, six.binary_type): + elif isinstance(s, bytes): return s.decode("utf-8", "ignore") else: return s # Not a string we care about diff --git a/redis/commands/search/aggregation.py b/redis/commands/search/aggregation.py index df912f896b..b391d1f55b 100644 --- a/redis/commands/search/aggregation.py +++ b/redis/commands/search/aggregation.py @@ -1,5 +1,3 @@ -from six import string_types - FIELDNAME = object() @@ -93,7 +91,7 @@ def __init__(self, fields, reducers): if not reducers: raise ValueError("Need at least one reducer") - fields = [fields] if isinstance(fields, string_types) else fields + fields = [fields] if isinstance(fields, str) else fields reducers = [reducers] if isinstance(reducers, Reducer) else reducers self.fields = fields @@ -299,7 +297,7 @@ def sort_by(self, *fields, **kwargs): .sort_by(Desc("@paid"), max=10) ``` """ - if isinstance(fields, (string_types, SortDirection)): + if isinstance(fields, (str, SortDirection)): fields = [fields] max = kwargs.get("max", 0) @@ -318,7 +316,7 @@ def filter(self, expressions): - **fields**: Fields to group by. This can either be a single string, or a list of strings. """ - if isinstance(expressions, string_types): + if isinstance(expressions, str): expressions = [expressions] for expression in expressions: diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py index 6074d2959b..296fb258ac 100644 --- a/redis/commands/search/commands.py +++ b/redis/commands/search/commands.py @@ -1,6 +1,5 @@ import itertools import time -import six from .document import Document from .result import Result @@ -308,9 +307,8 @@ def load_document(self, id): Load a single document by id """ fields = self.client.hgetall(id) - if six.PY3: - f2 = {to_string(k): to_string(v) for k, v in fields.items()} - fields = f2 + f2 = {to_string(k): to_string(v) for k, v in fields.items()} + fields = f2 try: del fields["id"] @@ -337,13 +335,13 @@ def info(self): """ res = self.client.execute_command(INFO_CMD, self.index_name) - it = six.moves.map(to_string, res) - return dict(six.moves.zip(it, it)) + it = map(to_string, res) + return dict(zip(it, it)) def _mk_query_args(self, query): args = [self.index_name] - if isinstance(query, six.string_types): + if isinstance(query, str): # convert the query from a text to a query object query = Query(query) if not isinstance(query, Query): @@ -448,7 +446,7 @@ def spellcheck(self, query, distance=None, include=None, exclude=None): return corrections for _correction in raw: - if isinstance(_correction, six.integer_types) and _correction == 0: + if isinstance(_correction, int) and _correction == 0: continue if len(_correction) != 3: diff --git a/redis/commands/search/document.py b/redis/commands/search/document.py index 26ede34ef1..0d4255db17 100644 --- a/redis/commands/search/document.py +++ b/redis/commands/search/document.py @@ -1,6 +1,3 @@ -import six - - class Document(object): """ Represents a single document in a result set @@ -9,7 +6,7 @@ class Document(object): def __init__(self, id, payload=None, **fields): self.id = id self.payload = payload - for k, v in six.iteritems(fields): + for k, v in fields.items(): setattr(self, k, v) def __repr__(self): diff --git a/redis/commands/search/query.py b/redis/commands/search/query.py index e2db7a422b..85a8255334 100644 --- a/redis/commands/search/query.py +++ b/redis/commands/search/query.py @@ -1,6 +1,3 @@ -import six - - class Query(object): """ Query is used to build complex queries that have more parameters than just @@ -66,7 +63,7 @@ def _mk_field_list(self, fields): if not fields: return [] return \ - [fields] if isinstance(fields, six.string_types) else list(fields) + [fields] if isinstance(fields, str) else list(fields) def summarize(self, fields=None, context_len=None, num_frags=None, sep=None): diff --git a/redis/commands/search/querystring.py b/redis/commands/search/querystring.py index f5f59b7e53..aecd3b82f5 100644 --- a/redis/commands/search/querystring.py +++ b/redis/commands/search/querystring.py @@ -1,6 +1,3 @@ -from six import string_types, integer_types - - def tags(*t): """ Indicate that the values should be matched to a tag field @@ -186,7 +183,7 @@ def __init__(self, *children, **kwparams): kvparams = {} for k, v in kwparams.items(): curvals = kvparams.setdefault(k, []) - if isinstance(v, (string_types, integer_types, float)): + if isinstance(v, (str, int, float)): curvals.append(Value.make_value(v)) elif isinstance(v, Value): curvals.append(v) diff --git a/redis/commands/search/result.py b/redis/commands/search/result.py index afc83f87cd..9cd922ac1d 100644 --- a/redis/commands/search/result.py +++ b/redis/commands/search/result.py @@ -1,5 +1,3 @@ -from six.moves import xrange, zip as izip - from .document import Document from ._util import to_string @@ -32,7 +30,7 @@ def __init__( offset = 2 if with_scores else 1 - for i in xrange(1, len(res), step): + for i in range(1, len(res), step): id = to_string(res[i]) payload = to_string(res[i + offset]) if has_payload else None # fields_offset = 2 if has_payload else 1 @@ -44,7 +42,7 @@ def __init__( fields = ( dict( dict( - izip( + zip( map(to_string, res[i + fields_offset][::2]), map(to_string, res[i + fields_offset][1::2]), ) diff --git a/redis/commands/search/suggestion.py b/redis/commands/search/suggestion.py index 550c514823..3401af94eb 100644 --- a/redis/commands/search/suggestion.py +++ b/redis/commands/search/suggestion.py @@ -1,4 +1,3 @@ -from six.moves import xrange from ._util import to_string @@ -45,7 +44,7 @@ def __init__(self, with_scores, with_payloads, ret): self._sugs = ret def __iter__(self): - for i in xrange(0, len(self._sugs), self.sugsize): + for i in range(0, len(self._sugs), self.sugsize): ss = self._sugs[i] score = float(self._sugs[i + self._scoreidx]) \ if self.with_scores else 1.0 From d93b5f32173c4ea43677ec594506298de95802ee Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 9 Nov 2021 12:17:18 +0200 Subject: [PATCH 0235/1164] 4.0.0 rc2 versioning (#1677) --- redis/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/__init__.py b/redis/__init__.py index 603f8464a2..c0dd690bc9 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -37,7 +37,7 @@ def int_or_str(value): return value -__version__ = '4.0.0rc1' +__version__ = '4.0.0rc2' VERSION = tuple(map(int_or_str, __version__.split('.'))) __all__ = [ From 3837e0d7bbe93622ce9ee40d754c630070e4f8e0 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 9 Nov 2021 13:14:19 +0200 Subject: [PATCH 0236/1164] Docstring improvements for Redis class (#1675) --- redis/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/redis/client.py b/redis/client.py index 935d9dd891..f2f1eed08c 100755 --- a/redis/client.py +++ b/redis/client.py @@ -613,8 +613,10 @@ class Redis(RedisModuleCommands, CoreCommands, object): This abstract class provides a Python interface to all Redis commands and an implementation of the Redis protocol. - Connection and Pipeline derive from this, implementing how - the commands are sent and received to the Redis server + Pipelines derive from this, implementing how + the commands are sent and received to the Redis server. Based on + configuration, an instance will either use a ConnectionPool, or + Connection object to talk to redis. """ RESPONSE_CALLBACKS = { **string_keys_to_dict( From b6c10985083d5d4ee7c66324096c68cc69598854 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 9 Nov 2021 13:23:45 +0200 Subject: [PATCH 0237/1164] Test function renames, to match standards (#1679) --- tests/test_timeseries.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index d3d474ff6f..99c60838f2 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -11,7 +11,7 @@ def client(modclient): @pytest.mark.redismod -def testCreate(client): +def test_create(client): assert client.ts().create(1) assert client.ts().create(2, retention_msecs=5) assert client.ts().create(3, labels={"Redis": "Labs"}) @@ -28,7 +28,7 @@ def testCreate(client): @pytest.mark.redismod @skip_ifmodversion_lt("1.4.0", "timeseries") -def testCreateDuplicatePolicy(client): +def test_create_duplicate_policy(client): # Test for duplicate policy for duplicate_policy in ["block", "last", "first", "min", "max"]: ts_name = "time-serie-ooo-{0}".format(duplicate_policy) @@ -38,7 +38,7 @@ def testCreateDuplicatePolicy(client): @pytest.mark.redismod -def testAlter(client): +def test_alter(client): assert client.ts().create(1) assert 0 == client.ts().info(1).retention_msecs assert client.ts().alter(1, retention_msecs=10) @@ -51,7 +51,7 @@ def testAlter(client): @pytest.mark.redismod @skip_ifmodversion_lt("1.4.0", "timeseries") -def testAlterDiplicatePolicy(client): +def test_alter_diplicate_policy(client): assert client.ts().create(1) info = client.ts().info(1) assert info.duplicate_policy is None @@ -61,7 +61,7 @@ def testAlterDiplicatePolicy(client): @pytest.mark.redismod -def testAdd(client): +def test_add(client): assert 1 == client.ts().add(1, 1, 1) assert 2 == client.ts().add(2, 2, 3, retention_msecs=10) assert 3 == client.ts().add(3, 3, 2, labels={"Redis": "Labs"}) @@ -83,7 +83,7 @@ def testAdd(client): @pytest.mark.redismod @skip_ifmodversion_lt("1.4.0", "timeseries") -def testAddDuplicatePolicy(client): +def test_add_duplicate_policy(client): # Test for duplicate policy BLOCK assert 1 == client.ts().add("time-serie-add-ooo-block", 1, 5.0) @@ -125,14 +125,14 @@ def testAddDuplicatePolicy(client): @pytest.mark.redismod -def testMAdd(client): +def test_madd(client): client.ts().create("a") assert [1, 2, 3] == \ client.ts().madd([("a", 1, 5), ("a", 2, 10), ("a", 3, 15)]) @pytest.mark.redismod -def testIncrbyDecrby(client): +def test_incrby_decrby(client): for _ in range(100): assert client.ts().incrby(1, 1) sleep(0.001) @@ -161,7 +161,7 @@ def testIncrbyDecrby(client): @pytest.mark.redismod -def testCreateAndDeleteRule(client): +def test_create_and_delete_rule(client): # test rule creation time = 100 client.ts().create(1) @@ -183,7 +183,7 @@ def testCreateAndDeleteRule(client): @pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "timeseries") -def testDelRange(client): +def test_del_range(client): try: client.ts().delete("test", 0, 100) except Exception as e: @@ -197,7 +197,7 @@ def testDelRange(client): @pytest.mark.redismod -def testRange(client): +def test_range(client): for i in range(100): client.ts().add(1, i, i % 7) assert 100 == len(client.ts().range(1, 0, 200)) @@ -219,7 +219,7 @@ def testRange(client): @pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "timeseries") -def testRangeAdvanced(client): +def test_range_advanced(client): for i in range(100): client.ts().add(1, i, i % 7) client.ts().add(1, i + 200, i % 7) @@ -244,7 +244,7 @@ def testRangeAdvanced(client): @pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "timeseries") -def testRevRange(client): +def test_rev_range(client): for i in range(100): client.ts().add(1, i, i % 7) assert 100 == len(client.ts().range(1, 0, 200)) @@ -318,7 +318,7 @@ def testMultiRange(client): @pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "timeseries") -def testMultiRangeAdvanced(client): +def test_multi_range_advanced(client): client.ts().create(1, labels={"Test": "This", "team": "ny"}) client.ts().create( 2, @@ -399,7 +399,7 @@ def testMultiRangeAdvanced(client): @pytest.mark.redismod @skip_ifmodversion_lt("99.99.99", "timeseries") -def testMultiReverseRange(client): +def test_multi_reverse_range(client): client.ts().create(1, labels={"Test": "This", "team": "ny"}) client.ts().create( 2, @@ -496,7 +496,7 @@ def testMultiReverseRange(client): @pytest.mark.redismod -def testGet(client): +def test_get(client): name = "test" client.ts().create(name) assert client.ts().get(name) is None @@ -507,7 +507,7 @@ def testGet(client): @pytest.mark.redismod -def testMGet(client): +def test_mget(client): client.ts().create(1, labels={"Test": "This"}) client.ts().create(2, labels={"Test": "This", "Taste": "That"}) act_res = client.ts().mget(["Test=This"]) @@ -528,7 +528,7 @@ def testMGet(client): @pytest.mark.redismod -def testInfo(client): +def test_info(client): client.ts().create( 1, retention_msecs=5, @@ -556,7 +556,7 @@ def testInfoDuplicatePolicy(client): @pytest.mark.redismod -def testQueryIndex(client): +def test_query_index(client): client.ts().create(1, labels={"Test": "This"}) client.ts().create(2, labels={"Test": "This", "Taste": "That"}) assert 2 == len(client.ts().queryindex(["Test=This"])) @@ -580,7 +580,7 @@ def test_pipeline(client): @pytest.mark.redismod -def testUncompressed(client): +def test_uncompressed(client): client.ts().create("compressed") client.ts().create("uncompressed", uncompressed=True) compressed_info = client.ts().info("compressed") From c19f120e069ab805d2a337beaed3de064e5875f7 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 9 Nov 2021 13:25:31 +0200 Subject: [PATCH 0238/1164] Sleep for flaky search test (#1680) --- tests/test_search.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_search.py b/tests/test_search.py index 926b5ff3af..7c9fdb288e 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -680,6 +680,10 @@ def test_alias(): # update alias and ensure new results ftindex2.aliasupdate("spaceballs") alias_client2 = getClient().ft("spaceballs") + + if os.environ.get("GITHUB_WORKFLOW", None) is not None: + time.sleep(5) + res = alias_client2.search("*").docs[0] assert "index2:yogurt" == res.id From f5160f57fcfe48838cc2082cd3c1c2b86d3bd36c Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 9 Nov 2021 16:18:29 +0200 Subject: [PATCH 0239/1164] Tests to validate built python packages (#1678) --- .github/workflows/install_and_test.sh | 41 +++++++++++++ .github/workflows/integration.yaml | 86 ++++++++++++++------------- dev_requirements.txt | 1 + tasks.py | 2 +- 4 files changed, 88 insertions(+), 42 deletions(-) create mode 100755 .github/workflows/install_and_test.sh diff --git a/.github/workflows/install_and_test.sh b/.github/workflows/install_and_test.sh new file mode 100755 index 0000000000..330102eb41 --- /dev/null +++ b/.github/workflows/install_and_test.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -e + +SUFFIX=$1 +if [ -z ${SUFFIX} ]; then + echo "Supply valid python package extension such as whl or tar.gz. Exiting." + exit 3 +fi + +script=`pwd`/${BASH_SOURCE[0]} +HERE=`dirname ${script}` +ROOT=`realpath ${HERE}/../..` + +cd ${ROOT} +DESTENV=${ROOT}/.venvforinstall +if [ -d ${DESTENV} ]; then + rm -rf ${DESTENV} +fi +python -m venv ${DESTENV} +source ${DESTENV}/bin/activate +pip install --upgrade --quiet pip +pip install --quiet -r dev_requirements.txt +invoke devenv +invoke package + +# find packages +PKG=`ls ${ROOT}/dist/*.${SUFFIX}` +ls -l ${PKG} + +TESTDIR=${ROOT}/STAGETESTS +if [ -d ${TESTDIR} ]; then + rm -rf ${TESTDIR} +fi +mkdir ${TESTDIR} +cp -R ${ROOT}/tests ${TESTDIR}/tests +cd ${TESTDIR} + +# install, run tests +pip install ${PKG} +pytest diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 4a073948cb..5384996c70 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -6,61 +6,65 @@ on: - 'docs/**' - '**/*.rst' - '**/*.md' + branches: + - master pull_request: branches: - master jobs: - lint: - name: Code linters - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: install python - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - name: run code linters - run: | - pip install -r dev_requirements.txt - invoke linters + lint: + name: Code linters + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: install python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: run code linters + run: | + pip install -r dev_requirements.txt + invoke linters - run-tests: - runs-on: ubuntu-latest - strategy: - max-parallel: 6 - matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.7'] - env: - ACTIONS_ALLOW_UNSECURE_COMMANDS: true - name: Python ${{ matrix.python-version }} tests - steps: - - uses: actions/checkout@v2 - - name: install python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: run tests - run: | - pip install -r dev_requirements.txt - invoke tests - - name: Upload codecov coverage - uses: codecov/codecov-action@v2 - with: - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} + run-tests: + runs-on: ubuntu-latest + strategy: + max-parallel: 6 + matrix: + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.7'] + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: true + name: Python ${{ matrix.python-version }} tests + steps: + - uses: actions/checkout@v2 + - name: install python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: run tests + run: | + pip install -r dev_requirements.txt + invoke tests + - name: Upload codecov coverage + uses: codecov/codecov-action@v2 + with: + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} - build_package: + build_and_test_package: name: Validate building and installing the package runs-on: ubuntu-latest + strategy: + matrix: + extension: ['tar.gz', 'whl'] steps: - uses: actions/checkout@v2 - name: install python uses: actions/setup-python@v2 with: python-version: 3.9 - - name: build and install + - name: Run installed unit tests run: | - pip install invoke - invoke package + bash .github/workflows/install_and_test.sh ${{ matrix.extension }} diff --git a/dev_requirements.txt b/dev_requirements.txt index 0ca7727049..6ea50550e8 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,3 +6,4 @@ tox-docker==3.1.0 invoke==1.6.0 pytest-cov>=3.0.0 vulture>=2.3.0 +wheel>=0.30.0 diff --git a/tasks.py b/tasks.py index 4ca2242fa9..306291c97f 100644 --- a/tasks.py +++ b/tasks.py @@ -56,4 +56,4 @@ def clean(c): @task def package(c): """Create the python packages""" - run("python setup.py build install") + run("python setup.py sdist bdist_wheel") From 2d7ceb0b110212fe50b8fa296f99f32f9a622ea7 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 10 Nov 2021 10:35:45 +0200 Subject: [PATCH 0240/1164] Test to validate custom JSON decoders (#1681) --- dev_requirements.txt | 1 + tests/test_json.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/dev_requirements.txt b/dev_requirements.txt index 6ea50550e8..7f099cb541 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,4 +6,5 @@ tox-docker==3.1.0 invoke==1.6.0 pytest-cov>=3.0.0 vulture>=2.3.0 +ujson>=4.2.0 wheel>=0.30.0 diff --git a/tests/test_json.py b/tests/test_json.py index b3f38f7ff2..abc5776316 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1003,6 +1003,7 @@ def test_debug_dollar(client): assert client.json().debug("MEMORY", "non_existing_doc", "$..a") == [] +@pytest.mark.redismod def test_resp_dollar(client): data = { @@ -1149,6 +1150,7 @@ def test_resp_dollar(client): assert client.json().resp("non_existing_doc", "$..a") is None +@pytest.mark.redismod def test_arrindex_dollar(client): client.json().set( @@ -1397,3 +1399,18 @@ def test_decoders_and_unstring(): assert decode_list(b"45.55") == 45.55 assert decode_list("45.55") == 45.55 assert decode_list(['hello', b'world']) == ['hello', 'world'] + + +@pytest.mark.redismod +def test_custom_decoder(client): + import ujson + import json + + cj = client.json(encoder=ujson, decoder=ujson) + assert cj.set("foo", Path.rootPath(), "bar") + assert "bar" == cj.get("foo") + assert cj.get("baz") is None + assert 1 == cj.delete("foo") + assert client.exists("foo") == 0 + assert not isinstance(cj.__encoder__, json.JSONEncoder) + assert not isinstance(cj.__decoder__, json.JSONDecoder) From a1d4e3e1373aa41ed607fb0358b1a2e09bf18f57 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 10 Nov 2021 10:39:12 +0200 Subject: [PATCH 0241/1164] Updating codecov rules (#1689) --- codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codecov.yml b/codecov.yml index 48cdae1902..449ec0c50f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,7 @@ +ignore: + - "benchmarks/**" + - "tasks.py" + codecov: require_ci_to_pass: yes From 939fead8d375cdc90c205ebd81b26d986d6d0dc3 Mon Sep 17 00:00:00 2001 From: Ariel Shtul Date: Wed, 10 Nov 2021 11:42:05 +0200 Subject: [PATCH 0242/1164] Adding RediSearch/RedisJSON tests (#1691) --- tests/test_search.py | 99 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/test_search.py b/tests/test_search.py index 7c9fdb288e..4175c53271 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1221,3 +1221,102 @@ def test_syndump(client): "baby": ["id2"], "offspring": ["id1"], } + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.2.0", "search") +def test_create_json_with_alias(client): + """ + Create definition with IndexType.JSON as index type (ON JSON) with two + fields with aliases, and use json client to test it. + """ + definition = IndexDefinition(prefix=["king:"], index_type=IndexType.JSON) + client.ft().create_index( + (TextField("$.name", as_name="name"), + NumericField("$.num", as_name="num")), + definition=definition + ) + + client.json().set("king:1", Path.rootPath(), {"name": "henry", + "num": 42}) + client.json().set("king:2", Path.rootPath(), {"name": "james", + "num": 3.14}) + + res = client.ft().search("@name:henry") + assert res.docs[0].id == "king:1" + assert res.docs[0].json == '{"name":"henry","num":42}' + assert res.total == 1 + + res = client.ft().search("@num:[0 10]") + assert res.docs[0].id == "king:2" + assert res.docs[0].json == '{"name":"james","num":3.14}' + assert res.total == 1 + + # Tests returns an error if path contain special characters (user should + # use an alias) + with pytest.raises(Exception): + client.ft().search("@$.name:henry") + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.2.0", "search") +def test_json_with_multipath(client): + """ + Create definition with IndexType.JSON as index type (ON JSON), + and use json client to test it. + """ + definition = IndexDefinition(prefix=["king:"], index_type=IndexType.JSON) + client.ft().create_index( + (TagField("$..name", as_name="name")), + definition=definition + ) + + client.json().set("king:1", Path.rootPath(), + {"name": "henry", "country": {"name": "england"}}) + + res = client.ft().search("@name:{henry}") + assert res.docs[0].id == "king:1" + assert res.docs[0].json == '{"name":"henry","country":{"name":"england"}}' + assert res.total == 1 + + res = client.ft().search("@name:{england}") + assert res.docs[0].id == "king:1" + assert res.docs[0].json == '{"name":"henry","country":{"name":"england"}}' + assert res.total == 1 + + +@pytest.mark.redismod +@skip_ifmodversion_lt("2.2.0", "search") +def test_json_with_jsonpath(client): + definition = IndexDefinition(index_type=IndexType.JSON) + client.ft().create_index( + (TextField('$["prod:name"]', as_name="name"), + TextField('$.prod:name', as_name="name_unsupported")), + definition=definition + ) + + client.json().set("doc:1", Path.rootPath(), {"prod:name": "RediSearch"}) + + # query for a supported field succeeds + res = client.ft().search(Query("@name:RediSearch")) + assert res.total == 1 + assert res.docs[0].id == "doc:1" + assert res.docs[0].json == '{"prod:name":"RediSearch"}' + + # query for an unsupported field fails + res = client.ft().search("@name_unsupported:RediSearch") + assert res.total == 0 + + # return of a supported field succeeds + res = client.ft().search(Query("@name:RediSearch").return_field("name")) + assert res.total == 1 + assert res.docs[0].id == "doc:1" + assert res.docs[0].name == 'RediSearch' + + # return of an unsupported field fails + res = client.ft().search(Query("@name:RediSearch") + .return_field("name_unsupported")) + assert res.total == 1 + assert res.docs[0].id == "doc:1" + with pytest.raises(Exception): + res.docs[0].name_unsupported From e07bd9464d6ce226e3db9a5eaf95bc2ecfe8e034 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 10 Nov 2021 15:09:09 +0200 Subject: [PATCH 0243/1164] Response parsing occasionally fails to parse floats (#1692) --- redis/commands/helpers.py | 5 +++- tests/test_helpers.py | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/test_helpers.py diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index 22cb6223b9..46eb83d603 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -42,7 +42,10 @@ def parse_to_list(response): try: res.append(int(item)) except ValueError: - res.append(nativestr(item)) + try: + res.append(float(item)) + except ValueError: + res.append(nativestr(item)) except TypeError: res.append(None) return res diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000000..467e00c1fd --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,49 @@ +import string +from redis.commands.helpers import ( + delist, + list_or_args, + nativestr, + parse_to_list, + quote_string, + random_string +) + + +def test_list_or_args(): + k = ["hello, world"] + a = ["some", "argument", "list"] + assert list_or_args(k, a) == k+a + + for i in ["banana", b"banana"]: + assert list_or_args(i, a) == [i] + a + + +def test_parse_to_list(): + r = ["hello", b"my name", "45", "555.55", "is simon!", None] + assert parse_to_list(r) == \ + ["hello", "my name", 45, 555.55, "is simon!", None] + + +def test_nativestr(): + assert nativestr('teststr') == 'teststr' + assert nativestr(b'teststr') == 'teststr' + assert nativestr('null') is None + + +def test_delist(): + assert delist(None) is None + assert delist([b'hello', 'world', b'banana']) == \ + ['hello', 'world', 'banana'] + + +def test_random_string(): + assert len(random_string()) == 10 + assert len(random_string(15)) == 15 + for a in random_string(): + assert a in string.ascii_lowercase + + +def test_quote_string(): + assert quote_string("hello world!") == '"hello world!"' + assert quote_string('') == '""' + assert quote_string('hello world!') == '"hello world!"' From 776dd5938a511052d5ce586dce66ac3508fc2e0e Mon Sep 17 00:00:00 2001 From: Ariel Shtul Date: Wed, 10 Nov 2021 15:10:19 +0200 Subject: [PATCH 0244/1164] [TEST] search alias test (#1695) --- tests/test_search.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/tests/test_search.py b/tests/test_search.py index 4175c53271..e07a61c977 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -646,25 +646,15 @@ def test_alias(): index1.hset("index1:lonestar", mapping={"name": "lonestar"}) index2.hset("index2:yogurt", mapping={"name": "yogurt"}) - if os.environ.get("GITHUB_WORKFLOW", None) is not None: - time.sleep(2) - else: - time.sleep(5) - - def1 = IndexDefinition(prefix=["index1:"], score_field="name") - def2 = IndexDefinition(prefix=["index2:"], score_field="name") + def1 = IndexDefinition(prefix=["index1:"]) + def2 = IndexDefinition(prefix=["index2:"]) ftindex1 = index1.ft("testAlias") - ftindex2 = index1.ft("testAlias2") + ftindex2 = index2.ft("testAlias2") ftindex1.create_index((TextField("name"),), definition=def1) ftindex2.create_index((TextField("name"),), definition=def2) - # CI is slower - try: - res = ftindex1.search("*").docs[0] - except IndexError: - time.sleep(5) - res = ftindex1.search("*").docs[0] + res = ftindex1.search("*").docs[0] assert "index1:lonestar" == res.id # create alias and check for results @@ -681,9 +671,6 @@ def test_alias(): ftindex2.aliasupdate("spaceballs") alias_client2 = getClient().ft("spaceballs") - if os.environ.get("GITHUB_WORKFLOW", None) is not None: - time.sleep(5) - res = alias_client2.search("*").docs[0] assert "index2:yogurt" == res.id From 5be96b96d6059a61d0fd50f96a32db99975408ed Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 10 Nov 2021 15:54:46 +0200 Subject: [PATCH 0245/1164] Unit test fixes to carry pytest options through all tests (#1696) --- tests/conftest.py | 20 +++++++++++++------- tests/test_connection.py | 2 ++ tests/test_connection_pool.py | 17 +++++++++-------- tests/test_monitor.py | 3 ++- tests/test_multiprocessing.py | 11 +++++++---- tests/test_pubsub.py | 3 ++- tests/test_sentinel.py | 2 +- 7 files changed, 36 insertions(+), 22 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b1a0f8cab8..0adec91aaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,6 @@ REDIS_INFO = {} default_redis_url = "redis://localhost:6379/9" -default_redismod_url = "redis://localhost:36379/9" default_redismod_url = "redis://localhost:36379" @@ -44,10 +43,13 @@ def pytest_sessionstart(session): REDIS_INFO["version"] = version REDIS_INFO["arch_bits"] = arch_bits - # module info - redismod_url = session.config.getoption("--redismod-url") - info = _get_info(redismod_url) - REDIS_INFO["modules"] = info["modules"] + # module info, if the second redis is running + try: + redismod_url = session.config.getoption("--redismod-url") + info = _get_info(redismod_url) + REDIS_INFO["modules"] = info["modules"] + except redis.exceptions.ConnectionError: + pass def skip_if_server_version_lt(min_version): @@ -72,7 +74,11 @@ def skip_unless_arch_bits(arch_bits): def skip_ifmodversion_lt(min_version: str, module_name: str): - modules = REDIS_INFO["modules"] + try: + modules = REDIS_INFO["modules"] + except KeyError: + return pytest.mark.skipif(True, + reason="Redis server does not have modules") if modules == []: return pytest.mark.skipif(True, reason="No redis modules found") @@ -218,7 +224,7 @@ def mock_cluster_resp_slaves(request, **kwargs): def master_host(request): url = request.config.getoption("--redis-url") parts = urlparse(url) - yield parts.hostname + yield parts.hostname, parts.port def wait_for_command(client, monitor, command): diff --git a/tests/test_connection.py b/tests/test_connection.py index fa9a2b0c90..f2fc834158 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -18,12 +18,14 @@ def test_invalid_response(r): @skip_if_server_version_lt('4.0.0') +@pytest.mark.redismod def test_loaded_modules(r, modclient): assert r.loaded_modules == [] assert 'rejson' in modclient.loaded_modules.keys() @skip_if_server_version_lt('4.0.0') +@pytest.mark.redismod def test_loading_external_modules(r, modclient): def inner(): pass diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 8d2ad041a0..6fedec66fb 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -44,14 +44,14 @@ def test_connection_creation(self): assert connection.kwargs == connection_kwargs def test_multiple_connections(self, master_host): - connection_kwargs = {'host': master_host} + connection_kwargs = {'host': master_host[0], 'port': master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') c2 = pool.get_connection('_') assert c1 != c2 def test_max_connections(self, master_host): - connection_kwargs = {'host': master_host} + connection_kwargs = {'host': master_host[0], 'port': master_host[1]} pool = self.get_pool(max_connections=2, connection_kwargs=connection_kwargs) pool.get_connection('_') @@ -60,7 +60,7 @@ def test_max_connections(self, master_host): pool.get_connection('_') def test_reuse_previously_released_connection(self, master_host): - connection_kwargs = {'host': master_host} + connection_kwargs = {'host': master_host[0], 'port': master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') pool.release(c1) @@ -103,14 +103,15 @@ def get_pool(self, connection_kwargs=None, max_connections=10, timeout=20): return pool def test_connection_creation(self, master_host): - connection_kwargs = {'foo': 'bar', 'biz': 'baz', 'host': master_host} + connection_kwargs = {'foo': 'bar', 'biz': 'baz', + 'host': master_host[0], 'port': master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) connection = pool.get_connection('_') assert isinstance(connection, DummyConnection) assert connection.kwargs == connection_kwargs def test_multiple_connections(self, master_host): - connection_kwargs = {'host': master_host} + connection_kwargs = {'host': master_host[0], 'port': master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') c2 = pool.get_connection('_') @@ -118,7 +119,7 @@ def test_multiple_connections(self, master_host): def test_connection_pool_blocks_until_timeout(self, master_host): "When out of connections, block for timeout seconds, then raise" - connection_kwargs = {'host': master_host} + connection_kwargs = {'host': master_host[0], 'port': master_host[1]} pool = self.get_pool(max_connections=1, timeout=0.1, connection_kwargs=connection_kwargs) pool.get_connection('_') @@ -134,7 +135,7 @@ def test_connection_pool_blocks_until_conn_available(self, master_host): When out of connections, block until another connection is released to the pool """ - connection_kwargs = {'host': master_host} + connection_kwargs = {'host': master_host[0], 'port': master_host[1]} pool = self.get_pool(max_connections=1, timeout=2, connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') @@ -149,7 +150,7 @@ def target(): assert time.time() - start >= 0.1 def test_reuse_previously_released_connection(self, master_host): - connection_kwargs = {'host': master_host} + connection_kwargs = {'host': master_host[0], 'port': master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) c1 = pool.get_connection('_') pool.release(c1) diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 1013202f22..bbb7fb75a4 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -9,11 +9,12 @@ def test_wait_command_not_found(self, r): assert response is None def test_response_values(self, r): + db = r.connection_pool.connection_kwargs.get('db', 0) with r.monitor() as m: r.ping() response = wait_for_command(r, m, 'PING') assert isinstance(response['time'], float) - assert response['db'] == 9 + assert response['db'] == db assert response['client_type'] in ('tcp', 'unix') assert isinstance(response['client_address'], str) assert isinstance(response['client_port'], str) diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index 2d27c4e8bb..d0feef155f 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -35,7 +35,7 @@ def test_close_connection_in_child(self, master_host): A connection owned by a parent and closed by a child doesn't destroy the file descriptors so a parent can still use it. """ - conn = Connection(host=master_host) + conn = Connection(host=master_host[0], port=master_host[1]) conn.send_command('ping') assert conn.read_response() == b'PONG' @@ -61,7 +61,7 @@ def test_close_connection_in_parent(self, master_host): A connection owned by a parent is unusable by a child if the parent (the owning process) closes the connection. """ - conn = Connection(host=master_host) + conn = Connection(host=master_host[0], port=master_host[1]) conn.send_command('ping') assert conn.read_response() == b'PONG' @@ -89,7 +89,9 @@ def test_pool(self, max_connections, master_host): A child will create its own connections when using a pool created by a parent. """ - pool = ConnectionPool.from_url('redis://{}'.format(master_host), + pool = ConnectionPool.from_url('redis://{}:{}'.format(master_host[0], + master_host[1], + ), max_connections=max_connections) conn = pool.get_connection('ping') @@ -124,7 +126,8 @@ def test_close_pool_in_main(self, max_connections, master_host): A child process that uses the same pool as its parent isn't affected when the parent disconnects all connections within the pool. """ - pool = ConnectionPool.from_url('redis://{}'.format(master_host), + pool = ConnectionPool.from_url('redis://{}:{}'.format(master_host[0], + master_host[1]), max_connections=max_connections) conn = pool.get_connection('ping') diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 4be6c7a305..cfc6e5e864 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -575,7 +575,8 @@ def exception_handler(ex, pubsub, thread): class TestPubSubDeadlock: @pytest.mark.timeout(30, method='thread') def test_pubsub_deadlock(self, master_host): - pool = redis.ConnectionPool(host=master_host) + pool = redis.ConnectionPool(host=master_host[0], + port=master_host[1]) r = redis.Redis(connection_pool=pool) for i in range(60): diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py index 54cf262c43..7f3ff0ae2a 100644 --- a/tests/test_sentinel.py +++ b/tests/test_sentinel.py @@ -10,7 +10,7 @@ @pytest.fixture(scope="module") def master_ip(master_host): - yield socket.gethostbyname(master_host) + yield socket.gethostbyname(master_host[0]) class SentinelTestClient: From 560457337c37ceaa16baaf65b1674e83463ecc20 Mon Sep 17 00:00:00 2001 From: AvitalFineRedis Date: Thu, 11 Nov 2021 09:50:51 +0100 Subject: [PATCH 0246/1164] Support desc for 3.5.3 --- redis/commands/core.py | 4 ++++ tests/test_commands.py | 1 + 2 files changed, 5 insertions(+) diff --git a/redis/commands/core.py b/redis/commands/core.py index 90997ff931..0344e3a433 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -2520,6 +2520,10 @@ def zrange(self, name, start, end, desc=False, withscores=False, ``offset`` and ``num`` are specified, then return a slice of the range. Can't be provided when using ``bylex``. """ + # Supports old implementation: need to support ``desc`` also for version < 6.2.0 + if not byscore and not bylex and (offset is None and num is None) and desc: + return self.zrevrange(name, start, end, withscores, + score_cast_func) return self._zrange('ZRANGE', None, name, start, end, desc, byscore, bylex, withscores, score_cast_func, offset, num) diff --git a/tests/test_commands.py b/tests/test_commands.py index 6d4ab008ab..c361a4ba70 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1867,6 +1867,7 @@ def test_zrange(self, r): assert r.zrange('a', 0, 1) == [b'a1', b'a2'] assert r.zrange('a', 1, 2) == [b'a2', b'a3'] assert r.zrange('a', 0, 2) == [b'a1', b'a2', b'a3'] + assert r.zrange('a', 0, 2, desc=True) == [b'a3', b'a2', b'a1'] # withscores assert r.zrange('a', 0, 1, withscores=True) == \ From cb58e968c2313e447b5b96fbbe8be1af306e1849 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 11 Nov 2021 11:30:28 +0100 Subject: [PATCH 0247/1164] Fix unit tests running against Redis 4.0.0 (#1699) --- tests/test_commands.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 6d4ab008ab..4991f89745 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -453,7 +453,7 @@ def test_client_kill_filter_by_laddr(self, r, r2): client_2_addr = clients_by_name['redis-py-c2'].get('laddr') assert r.client_kill_filter(laddr=client_2_addr) - @skip_if_server_version_lt('2.8.12') + @skip_if_server_version_lt('6.0.0') def test_client_kill_filter_by_user(self, r, request): killuser = 'user_to_kill' r.acl_setuser(killuser, enabled=True, reset=True, @@ -3660,7 +3660,8 @@ def test_restore(self, r): assert r.restore(key2, 0, dumpdata) assert r.ttl(key2) == -1 - # idletime + @skip_if_server_version_lt('5.0.0') + def test_restore_idletime(self, r): key = 'yayakey' r.set(key, 'blee!') dumpdata = r.dump(key) @@ -3668,7 +3669,8 @@ def test_restore(self, r): assert r.restore(key, 0, dumpdata, idletime=5) assert r.get(key) == b'blee!' - # frequency + @skip_if_server_version_lt('5.0.0') + def test_restore_frequency(self, r): key = 'yayakey' r.set(key, 'blee!') dumpdata = r.dump(key) @@ -3678,10 +3680,8 @@ def test_restore(self, r): @skip_if_server_version_lt('5.0.0') def test_replicaof(self, r): - with pytest.raises(redis.ResponseError): assert r.replicaof("NO ONE") - assert r.replicaof("NO", "ONE") From ec172e74bbccd32627835d67eddac704fe9ba31b Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 11 Nov 2021 11:31:19 +0100 Subject: [PATCH 0248/1164] Restoring ZRANGE desc for Redis < 6.2.0 (#1697) --- redis/commands/core.py | 7 +++++++ tests/test_commands.py | 1 + 2 files changed, 8 insertions(+) diff --git a/redis/commands/core.py b/redis/commands/core.py index 90997ff931..67f1bfa1be 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -2520,6 +2520,13 @@ def zrange(self, name, start, end, desc=False, withscores=False, ``offset`` and ``num`` are specified, then return a slice of the range. Can't be provided when using ``bylex``. """ + # Need to support ``desc`` also when using old redis version + # because it was supported in 3.5.3 (of redis-py) + if not byscore and not bylex and (offset is None and num is None) \ + and desc: + return self.zrevrange(name, start, end, withscores, + score_cast_func) + return self._zrange('ZRANGE', None, name, start, end, desc, byscore, bylex, withscores, score_cast_func, offset, num) diff --git a/tests/test_commands.py b/tests/test_commands.py index 4991f89745..330ba280f9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1867,6 +1867,7 @@ def test_zrange(self, r): assert r.zrange('a', 0, 1) == [b'a1', b'a2'] assert r.zrange('a', 1, 2) == [b'a2', b'a3'] assert r.zrange('a', 0, 2) == [b'a1', b'a2', b'a3'] + assert r.zrange('a', 0, 2, desc=True) == [b'a3', b'a2', b'a1'] # withscores assert r.zrange('a', 0, 1, withscores=True) == \ From 6a293e685d27894bc99ea4c0c7312c81f099ca45 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 11 Nov 2021 12:38:27 +0200 Subject: [PATCH 0249/1164] Fixes to allow --redis-url to pass through all tests (#1700) --- tests/conftest.py | 18 +++++++++++++ tests/test_commands.py | 50 ++++++++++++++++++++++++++--------- tests/test_connection_pool.py | 12 ++++++++- tests/test_monitor.py | 15 ++++++++++- tests/test_pubsub.py | 7 ++++- tests/test_scripting.py | 11 +++++++- 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0adec91aaa..bb682f78ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,12 @@ def pytest_addoption(parser): def _get_info(redis_url): client = redis.Redis.from_url(redis_url) info = client.info() + try: + client.execute_command("CONFIG SET maxmemory 5555555") + client.execute_command("CONFIG SET maxmemory 0") + info["enterprise"] = False + except redis.exceptions.ResponseError: + info["enterprise"] = True client.connection_pool.disconnect() return info @@ -42,6 +48,7 @@ def pytest_sessionstart(session): arch_bits = info["arch_bits"] REDIS_INFO["version"] = version REDIS_INFO["arch_bits"] = arch_bits + REDIS_INFO["enterprise"] = info["enterprise"] # module info, if the second redis is running try: @@ -92,6 +99,17 @@ def skip_ifmodversion_lt(min_version: str, module_name: str): raise AttributeError("No redis module named {}".format(module_name)) +def skip_if_redis_enterprise(func): + check = REDIS_INFO["enterprise"] is True + return pytest.mark.skipif(check, reason="Redis enterprise" + ) + + +def skip_ifnot_redis_enterprise(func): + check = REDIS_INFO["enterprise"] is False + return pytest.mark.skipif(check, reason="Redis enterprise") + + def _get_client(cls, request, single_connection_client=True, flushdb=True, from_url=None, **kwargs): diff --git a/tests/test_commands.py b/tests/test_commands.py index 330ba280f9..df561d4d5d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -13,6 +13,7 @@ _get_client, skip_if_server_version_gte, skip_if_server_version_lt, + skip_if_redis_enterprise, skip_unless_arch_bits, ) @@ -80,6 +81,7 @@ def test_acl_cat_with_category(self, r): assert 'get' in commands @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise def test_acl_deluser(self, r, request): username = 'redis-py-user' @@ -104,6 +106,7 @@ def teardown(): assert r.acl_getuser(users[4]) is None @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise def test_acl_genpass(self, r): password = r.acl_genpass() assert isinstance(password, str) @@ -117,6 +120,7 @@ def test_acl_genpass(self, r): assert isinstance(password, str) @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise def test_acl_getuser_setuser(self, r, request): username = 'redis-py-user' @@ -210,6 +214,7 @@ def test_acl_help(self, r): assert len(res) != 0 @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise def test_acl_list(self, r, request): username = 'redis-py-user' @@ -222,6 +227,7 @@ def teardown(): assert len(users) == 2 @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise def test_acl_log(self, r, request): username = 'redis-py-user' @@ -257,6 +263,7 @@ def teardown(): assert r.acl_log_reset() @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise def test_acl_setuser_categories_without_prefix_fails(self, r, request): username = 'redis-py-user' @@ -268,6 +275,7 @@ def teardown(): r.acl_setuser(username, categories=['list']) @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise def test_acl_setuser_commands_without_prefix_fails(self, r, request): username = 'redis-py-user' @@ -279,6 +287,7 @@ def teardown(): r.acl_setuser(username, commands=['get']) @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request): username = 'redis-py-user' @@ -312,13 +321,18 @@ def test_client_info(self, r): assert 'addr' in info @skip_if_server_version_lt('5.0.0') - def test_client_list_type(self, r): + def test_client_list_types_not_replica(self, r): with pytest.raises(exceptions.RedisError): r.client_list(_type='not a client type') - for client_type in ['normal', 'master', 'replica', 'pubsub']: + for client_type in ['normal', 'master', 'pubsub']: clients = r.client_list(_type=client_type) assert isinstance(clients, list) + @skip_if_redis_enterprise + def test_client_list_replica(self, r): + clients = r.client_list(_type='replica') + assert isinstance(clients, list) + @skip_if_server_version_lt('6.2.0') def test_client_list_client_id(self, r, request): clients = r.client_list() @@ -454,6 +468,7 @@ def test_client_kill_filter_by_laddr(self, r, r2): assert r.client_kill_filter(laddr=client_2_addr) @skip_if_server_version_lt('6.0.0') + @skip_if_redis_enterprise def test_client_kill_filter_by_user(self, r, request): killuser = 'user_to_kill' r.acl_setuser(killuser, enabled=True, reset=True, @@ -467,6 +482,7 @@ def test_client_kill_filter_by_user(self, r, request): r.acl_deluser(killuser) @skip_if_server_version_lt('2.9.50') + @skip_if_redis_enterprise def test_client_pause(self, r): assert r.client_pause(1) assert r.client_pause(timeout=1) @@ -474,6 +490,7 @@ def test_client_pause(self, r): r.client_pause(timeout='not an integer') @skip_if_server_version_lt('6.2.0') + @skip_if_redis_enterprise def test_client_unpause(self, r): assert r.client_unpause() == b'OK' @@ -491,15 +508,18 @@ def test_client_reply(self, r, r_timeout): assert r.get('foo') == b'bar' @skip_if_server_version_lt('6.0.0') + @skip_if_redis_enterprise def test_client_getredir(self, r): assert isinstance(r.client_getredir(), int) assert r.client_getredir() == -1 def test_config_get(self, r): data = r.config_get() - assert 'maxmemory' in data - assert data['maxmemory'].isdigit() + assert len(data.keys()) > 10 + # # assert 'maxmemory' in data + # assert data['maxmemory'].isdigit() + @skip_if_redis_enterprise def test_config_resetstat(self, r): r.ping() prior_commands_processed = int(r.info()['total_commands_processed']) @@ -508,14 +528,12 @@ def test_config_resetstat(self, r): reset_commands_processed = int(r.info()['total_commands_processed']) assert reset_commands_processed < prior_commands_processed + @skip_if_redis_enterprise def test_config_set(self, r): - data = r.config_get() - rdbname = data['dbfilename'] - try: - assert r.config_set('dbfilename', 'redis_py_test.rdb') - assert r.config_get()['dbfilename'] == 'redis_py_test.rdb' - finally: - assert r.config_set('dbfilename', rdbname) + r.config_set('timeout', 70) + assert r.config_get()['timeout'] == '70' + assert r.config_set('timeout', 0) + assert r.config_get()['timeout'] == '0' def test_dbsize(self, r): r['a'] = 'foo' @@ -530,8 +548,10 @@ def test_info(self, r): r['b'] = 'bar' info = r.info() assert isinstance(info, dict) - assert info['db9']['keys'] == 2 + assert 'arch_bits' in info.keys() + assert 'redis_version' in info.keys() + @skip_if_redis_enterprise def test_lastsave(self, r): assert isinstance(r.lastsave(), datetime.datetime) @@ -625,6 +645,7 @@ def test_time(self, r): assert isinstance(t[0], int) assert isinstance(t[1], int) + @skip_if_redis_enterprise def test_bgsave(self, r): assert r.bgsave() time.sleep(0.3) @@ -2433,6 +2454,7 @@ def test_cluster_slaves(self, mock_cluster_resp_slaves): 'slaves', 'nodeid'), dict) @skip_if_server_version_lt('3.0.0') + @skip_if_redis_enterprise def test_readwrite(self, r): assert r.readwrite() @@ -3614,6 +3636,7 @@ def test_memory_usage(self, r): assert isinstance(r.memory_usage('foo'), int) @skip_if_server_version_lt('4.0.0') + @skip_if_redis_enterprise def test_module_list(self, r): assert isinstance(r.module_list(), list) for x in r.module_list(): @@ -3626,6 +3649,7 @@ def test_command_count(self, r): assert res >= 100 @skip_if_server_version_lt('4.0.0') + @skip_if_redis_enterprise def test_module(self, r): with pytest.raises(redis.exceptions.ModuleError) as excinfo: r.module_load('/some/fake/path') @@ -3680,6 +3704,7 @@ def test_restore_frequency(self, r): assert r.get(key) == b'blee!' @skip_if_server_version_lt('5.0.0') + @skip_if_redis_enterprise def test_replicaof(self, r): with pytest.raises(redis.ResponseError): assert r.replicaof("NO ONE") @@ -3756,6 +3781,7 @@ def test_22_info(self, r): assert '6' in parsed['allocation_stats'] assert '>=256' in parsed['allocation_stats'] + @skip_if_redis_enterprise def test_large_responses(self, r): "The PythonParser has some special cases for return values > 1MB" # load up 5MB of data into a key diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 6fedec66fb..521f520777 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -7,7 +7,11 @@ from threading import Thread from redis.connection import ssl_available, to_bool -from .conftest import skip_if_server_version_lt, _get_client +from .conftest import ( + skip_if_server_version_lt, + skip_if_redis_enterprise, + _get_client +) from .test_pubsub import wait_for_message @@ -481,6 +485,7 @@ def test_on_connect_error(self): assert not pool._available_connections[0]._sock @skip_if_server_version_lt('2.8.8') + @skip_if_redis_enterprise def test_busy_loading_disconnects_socket(self, r): """ If Redis raises a LOADING error, the connection should be @@ -491,6 +496,7 @@ def test_busy_loading_disconnects_socket(self, r): assert not r.connection._sock @skip_if_server_version_lt('2.8.8') + @skip_if_redis_enterprise def test_busy_loading_from_pipeline_immediate_command(self, r): """ BusyLoadingErrors should raise from Pipelines that execute a @@ -506,6 +512,7 @@ def test_busy_loading_from_pipeline_immediate_command(self, r): assert not pool._available_connections[0]._sock @skip_if_server_version_lt('2.8.8') + @skip_if_redis_enterprise def test_busy_loading_from_pipeline(self, r): """ BusyLoadingErrors should be raised from a pipeline execution @@ -521,6 +528,7 @@ def test_busy_loading_from_pipeline(self, r): assert not pool._available_connections[0]._sock @skip_if_server_version_lt('2.8.8') + @skip_if_redis_enterprise def test_read_only_error(self, r): "READONLY errors get turned in ReadOnlyError exceptions" with pytest.raises(redis.ReadOnlyError): @@ -546,6 +554,7 @@ def test_connect_from_url_unix(self): 'path=/path/to/socket,db=0', ) + @skip_if_redis_enterprise def test_connect_no_auth_supplied_when_required(self, r): """ AuthenticationError should be raised when the server requires a @@ -555,6 +564,7 @@ def test_connect_no_auth_supplied_when_required(self, r): r.execute_command('DEBUG', 'ERROR', 'ERR Client sent AUTH, but no password is set') + @skip_if_redis_enterprise def test_connect_invalid_password_supplied(self, r): "AuthenticationError should be raised when sending the wrong password" with pytest.raises(redis.AuthenticationError): diff --git a/tests/test_monitor.py b/tests/test_monitor.py index bbb7fb75a4..a8a535b59a 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -1,4 +1,8 @@ -from .conftest import wait_for_command +from .conftest import ( + skip_if_redis_enterprise, + skip_ifnot_redis_enterprise, + wait_for_command +) class TestMonitor: @@ -40,6 +44,7 @@ def test_command_with_escaped_data(self, r): response = wait_for_command(r, m, 'GET foo\\\\x92') assert response['command'] == 'GET foo\\\\x92' + @skip_if_redis_enterprise def test_lua_script(self, r): with r.monitor() as m: script = 'return redis.call("GET", "foo")' @@ -49,3 +54,11 @@ def test_lua_script(self, r): assert response['client_type'] == 'lua' assert response['client_address'] == 'lua' assert response['client_port'] == '' + + @skip_ifnot_redis_enterprise + def test_lua_script_in_enterprise(self, r): + with r.monitor() as m: + script = 'return redis.call("GET", "foo")' + assert r.eval(script, 0) is None + response = wait_for_command(r, m, 'GET foo') + assert response is None diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index cfc6e5e864..e2424592cd 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -7,7 +7,11 @@ import redis from redis.exceptions import ConnectionError -from .conftest import _get_client, skip_if_server_version_lt +from .conftest import ( + _get_client, + skip_if_redis_enterprise, + skip_if_server_version_lt +) def wait_for_message(pubsub, timeout=0.1, ignore_subscribe_messages=False): @@ -528,6 +532,7 @@ def test_send_pubsub_ping_message(self, r): class TestPubSubConnectionKilled: @skip_if_server_version_lt('3.0.0') + @skip_if_redis_enterprise def test_connection_error_raised_when_connection_dies(self, r): p = r.pubsub() p.subscribe('foo') diff --git a/tests/test_scripting.py b/tests/test_scripting.py index c3c2094d4a..352f3bae2e 100644 --- a/tests/test_scripting.py +++ b/tests/test_scripting.py @@ -2,6 +2,8 @@ from redis import exceptions +from tests.conftest import skip_if_server_version_lt + multiply_script = """ local value = redis.call('GET', KEYS[1]) @@ -30,7 +32,8 @@ def test_eval(self, r): # 2 * 3 == 6 assert r.eval(multiply_script, 1, 'a', 3) == 6 - def test_script_flush(self, r): + @skip_if_server_version_lt('6.2.0') + def test_script_flush_620(self, r): r.set('a', 2) r.script_load(multiply_script) r.script_flush('ASYNC') @@ -43,6 +46,12 @@ def test_script_flush(self, r): r.script_load(multiply_script) r.script_flush() + with pytest.raises(exceptions.DataError): + r.set('a', 2) + r.script_load(multiply_script) + r.script_flush("NOTREAL") + + def test_script_flush(self, r): r.set('a', 2) r.script_load(multiply_script) r.script_flush(None) From e881976023cfc381aeaaec580c4b10b9ba62c0b6 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 14 Nov 2021 12:50:06 +0200 Subject: [PATCH 0250/1164] Added breaking icon to release drafter (#1702) --- .github/release-drafter-config.yml | 2 +- tests/test_commands.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/release-drafter-config.yml b/.github/release-drafter-config.yml index f17a2992fa..a3a5d8392a 100644 --- a/.github/release-drafter-config.yml +++ b/.github/release-drafter-config.yml @@ -15,7 +15,7 @@ autolabeler: branch: - '/feature-.+' categories: - - title: 'Breaking Changes' + - title: '🔥 Breaking Changes' labels: - 'breakingchange' - title: '🚀 New Features' diff --git a/tests/test_commands.py b/tests/test_commands.py index df561d4d5d..6cb1a78279 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1208,6 +1208,12 @@ def test_stralgo_lcs(self, r): value1 = 'ohmytext' value2 = 'mynewtext' res = 'mytext' + + if skip_if_redis_enterprise(None).args[0] is True: + with pytest.raises(redis.exceptions.ResponseError): + assert r.stralgo('LCS', value1, value2) == res + return + # test LCS of strings assert r.stralgo('LCS', value1, value2) == res # test using keys @@ -1250,6 +1256,12 @@ def test_strlen(self, r): def test_substr(self, r): r['a'] = '0123456789' + + if skip_if_redis_enterprise(None).args[0] is True: + with pytest.raises(redis.exceptions.ResponseError): + assert r.substr('a', 0) == b'0123456789' + return + assert r.substr('a', 0) == b'0123456789' assert r.substr('a', 2) == b'23456789' assert r.substr('a', 3, 5) == b'345' @@ -3617,6 +3629,11 @@ def test_memory_doctor(self, r): @skip_if_server_version_lt('4.0.0') def test_memory_malloc_stats(self, r): + if skip_if_redis_enterprise(None).args[0] is True: + with pytest.raises(redis.exceptions.ResponseError): + assert r.memory_malloc_stats() + return + assert r.memory_malloc_stats() @skip_if_server_version_lt('4.0.0') @@ -3624,6 +3641,12 @@ def test_memory_stats(self, r): # put a key into the current db to make sure that "db." # has data r.set('foo', 'bar') + + if skip_if_redis_enterprise(None).args[0] is True: + with pytest.raises(redis.exceptions.ResponseError): + stats = r.memory_stats() + return + stats = r.memory_stats() assert isinstance(stats, dict) for key, value in stats.items(): From 5b72987e80a7765ce6e464232a7f1519243d0a73 Mon Sep 17 00:00:00 2001 From: Jeremy Mayeres <1524722+jerr0328@users.noreply.github.com> Date: Sun, 14 Nov 2021 11:52:57 +0100 Subject: [PATCH 0251/1164] Improve documentation about Lock (#1701) --- redis/lock.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/redis/lock.py b/redis/lock.py index 326dbaf98e..d2297526a0 100644 --- a/redis/lock.py +++ b/redis/lock.py @@ -76,14 +76,14 @@ def __init__(self, redis, name, timeout=None, sleep=0.1, Create a new Lock instance named ``name`` using the Redis client supplied by ``redis``. - ``timeout`` indicates a maximum life for the lock. + ``timeout`` indicates a maximum life for the lock in seconds. By default, it will remain locked until release() is called. ``timeout`` can be specified as a float or integer, both representing the number of seconds to wait. - ``sleep`` indicates the amount of time to sleep per loop iteration - when the lock is in blocking mode and another client is currently - holding the lock. + ``sleep`` indicates the amount of time to sleep in seconds per loop + iteration when the lock is in blocking mode and another client is + currently holding the lock. ``blocking`` indicates whether calling ``acquire`` should block until the lock has been acquired or to fail immediately, causing ``acquire`` From 19819e08211cc12f85c45294ad6209b2d5626337 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 14 Nov 2021 13:09:43 +0200 Subject: [PATCH 0252/1164] Unit tests fixes for compatibility (#1703) --- redis/client.py | 41 +++++++++++++-------------- redis/commands/core.py | 3 ++ redis/commands/redismodules.py | 38 ++++++++++++------------- redis/commands/search/__init__.py | 3 +- redis/commands/timeseries/__init__.py | 3 +- tests/conftest.py | 10 +++---- tests/test_commands.py | 8 ++++++ tests/test_connection.py | 26 ++++++----------- 8 files changed, 65 insertions(+), 67 deletions(-) diff --git a/redis/client.py b/redis/client.py index f2f1eed08c..753770e3e2 100755 --- a/redis/client.py +++ b/redis/client.py @@ -703,7 +703,6 @@ class Redis(RedisModuleCommands, CoreCommands, object): 'CLUSTER SET-CONFIG-EPOCH': bool_ok, 'CLUSTER SETSLOT': bool_ok, 'CLUSTER SLAVES': parse_cluster_nodes, - 'COMMAND': int, 'COMMAND COUNT': int, 'CONFIG GET': parse_config_get, 'CONFIG RESETSTAT': bool_ok, @@ -891,6 +890,12 @@ def __init__(self, host='localhost', port=6379, self.response_callbacks = CaseInsensitiveDict( self.__class__.RESPONSE_CALLBACKS) + # preload our class with the available redis commands + try: + self.__redis_commands__() + except RedisError: + pass + def __repr__(self): return "%s<%s>" % (type(self).__name__, repr(self.connection_pool)) @@ -898,12 +903,12 @@ def set_response_callback(self, command, callback): "Set a custom Response Callback" self.response_callbacks[command] = callback - def load_external_module(self, modname, funcname, func): + def load_external_module(self, funcname, func, + ): """ This function can be used to add externally defined redis modules, and their namespaces to the redis client. - modname - A string containing the name of the redis module to look for - in the redis info block. + funcname - A string containing the name of the function to create func - The function, being added to this class. @@ -914,31 +919,25 @@ def load_external_module(self, modname, funcname, func): from redis import Redis from foomodule import F r = Redis() - r.load_external_module("foomod", "foo", F) + r.load_external_module("foo", F) r.foo().dothing('your', 'arguments') For a concrete example see the reimport of the redisjson module in tests/test_connection.py::test_loading_external_modules """ - mods = self.loaded_modules - if modname.lower() not in mods: - raise ModuleError("{} is not loaded in redis.".format(modname)) setattr(self, funcname, func) - @property - def loaded_modules(self): - key = '__redis_modules__' - mods = getattr(self, key, None) - if mods is not None: - return mods - + def __redis_commands__(self): + """Store the list of available commands, for our redis instance.""" + cmds = getattr(self, '__commands__', None) + if cmds is not None: + return cmds try: - mods = {f.get('name').lower(): f.get('ver') - for f in self.info().get('modules')} - except TypeError: - mods = [] - setattr(self, key, mods) - return mods + cmds = [c[0].upper().decode() for c in self.command()] + except AttributeError: # if encoded + cmds = [c[0].upper() for c in self.command()] + self.__commands__ = cmds + return cmds def pipeline(self, transaction=True, shard_hint=None): """ diff --git a/redis/commands/core.py b/redis/commands/core.py index 67f1bfa1be..516e7d9c83 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -3315,6 +3315,9 @@ def command_info(self): def command_count(self): return self.execute_command('COMMAND COUNT') + def command(self): + return self.execute_command('COMMAND') + class Script: "An executable Lua script object returned by ``register_script``" diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py index 457a69e2a2..b3cbee1d87 100644 --- a/redis/commands/redismodules.py +++ b/redis/commands/redismodules.py @@ -8,41 +8,41 @@ class RedisModuleCommands: """ def json(self, encoder=JSONEncoder(), decoder=JSONDecoder()): - """Access the json namespace, providing support for redis json.""" - try: - modversion = self.loaded_modules['rejson'] - except IndexError: - raise ModuleError("rejson is not a loaded in the redis instance.") + """Access the json namespace, providing support for redis json. + """ + if 'JSON.SET' not in self.__commands__: + raise ModuleError("redisjson is not loaded in redis. " + "For more information visit " + "https://redisjson.io/") from .json import JSON jj = JSON( client=self, - version=modversion, encoder=encoder, decoder=decoder) return jj def ft(self, index_name="idx"): - """Access the search namespace, providing support for redis search.""" - try: - modversion = self.loaded_modules['search'] - except IndexError: - raise ModuleError("search is not a loaded in the redis instance.") + """Access the search namespace, providing support for redis search. + """ + if 'FT.INFO' not in self.__commands__: + raise ModuleError("redisearch is not loaded in redis. " + "For more information visit " + "https://redisearch.io/") from .search import Search - s = Search(client=self, version=modversion, index_name=index_name) + s = Search(client=self, index_name=index_name) return s - def ts(self, index_name="idx"): + def ts(self): """Access the timeseries namespace, providing support for redis timeseries data. """ - try: - modversion = self.loaded_modules['timeseries'] - except IndexError: - raise ModuleError("timeseries is not a loaded in " - "the redis instance.") + if 'TS.INFO' not in self.__commands__: + raise ModuleError("reditimeseries is not loaded in redis. " + "For more information visit " + "https://redistimeseries.io/") from .timeseries import TimeSeries - s = TimeSeries(client=self, version=modversion, index_name=index_name) + s = TimeSeries(client=self) return s diff --git a/redis/commands/search/__init__.py b/redis/commands/search/__init__.py index 425578eabd..8320ad4392 100644 --- a/redis/commands/search/__init__.py +++ b/redis/commands/search/__init__.py @@ -83,7 +83,7 @@ def commit(self): self.pipeline.execute() self.current_chunk = 0 - def __init__(self, client, version=None, index_name="idx"): + def __init__(self, client, index_name="idx"): """ Create a new Client for the given index_name. The default name is `idx` @@ -91,7 +91,6 @@ def __init__(self, client, version=None, index_name="idx"): If conn is not None, we employ an already existing redis connection """ self.client = client - self.MODULE_VERSION = version self.index_name = index_name self.execute_command = client.execute_command self.pipeline = client.pipeline diff --git a/redis/commands/timeseries/__init__.py b/redis/commands/timeseries/__init__.py index 83fa17082e..5ce538f675 100644 --- a/redis/commands/timeseries/__init__.py +++ b/redis/commands/timeseries/__init__.py @@ -34,7 +34,7 @@ class TimeSeries(TimeSeriesCommands): functionality. """ - def __init__(self, client=None, version=None, **kwargs): + def __init__(self, client=None, **kwargs): """Create a new RedisTimeSeries client.""" # Set the module commands' callbacks self.MODULE_CALLBACKS = { @@ -55,7 +55,6 @@ def __init__(self, client=None, version=None, **kwargs): self.client = client self.execute_command = client.execute_command - self.MODULE_VERSION = version for key, value in self.MODULE_CALLBACKS.items(): self.client.set_response_callback(key, value) diff --git a/tests/conftest.py b/tests/conftest.py index bb682f78ca..31d3fbd1a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,12 +31,10 @@ def pytest_addoption(parser): def _get_info(redis_url): client = redis.Redis.from_url(redis_url) info = client.info() - try: - client.execute_command("CONFIG SET maxmemory 5555555") - client.execute_command("CONFIG SET maxmemory 0") - info["enterprise"] = False - except redis.exceptions.ResponseError: + if 'dping' in client.__commands__: info["enterprise"] = True + else: + info["enterprise"] = False client.connection_pool.disconnect() return info @@ -57,6 +55,8 @@ def pytest_sessionstart(session): REDIS_INFO["modules"] = info["modules"] except redis.exceptions.ConnectionError: pass + except KeyError: + pass def skip_if_server_version_lt(min_version): diff --git a/tests/test_commands.py b/tests/test_commands.py index 6cb1a78279..dbd04429b4 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3671,6 +3671,14 @@ def test_command_count(self, r): assert isinstance(res, int) assert res >= 100 + @skip_if_server_version_lt('2.8.13') + def test_command(self, r): + res = r.command() + assert len(res) >= 100 + cmds = [c[0].decode() for c in res] + assert 'set' in cmds + assert 'get' in cmds + @skip_if_server_version_lt('4.0.0') @skip_if_redis_enterprise def test_module(self, r): diff --git a/tests/test_connection.py b/tests/test_connection.py index f2fc834158..7c44768150 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -2,7 +2,7 @@ import types import pytest -from redis.exceptions import InvalidResponse, ModuleError +from redis.exceptions import InvalidResponse from redis.utils import HIREDIS_AVAILABLE from .conftest import skip_if_server_version_lt @@ -19,30 +19,20 @@ def test_invalid_response(r): @skip_if_server_version_lt('4.0.0') @pytest.mark.redismod -def test_loaded_modules(r, modclient): - assert r.loaded_modules == [] - assert 'rejson' in modclient.loaded_modules.keys() - - -@skip_if_server_version_lt('4.0.0') -@pytest.mark.redismod -def test_loading_external_modules(r, modclient): +def test_loading_external_modules(modclient): def inner(): pass - with pytest.raises(ModuleError): - r.load_external_module('rejson', 'myfuncname', inner) - - modclient.load_external_module('rejson', 'myfuncname', inner) + modclient.load_external_module('myfuncname', inner) assert getattr(modclient, 'myfuncname') == inner assert isinstance(getattr(modclient, 'myfuncname'), types.FunctionType) # and call it from redis.commands import RedisModuleCommands j = RedisModuleCommands.json - modclient.load_external_module('rejson', 'sometestfuncname', j) + modclient.load_external_module('sometestfuncname', j) - d = {'hello': 'world!'} - mod = j(modclient) - mod.set("fookey", ".", d) - assert mod.get('fookey') == d + # d = {'hello': 'world!'} + # mod = j(modclient) + # mod.set("fookey", ".", d) + # assert mod.get('fookey') == d From c8cb7150abe99b77f9f6bde1fba8ecb527295110 Mon Sep 17 00:00:00 2001 From: Ariel Shtul Date: Sun, 14 Nov 2021 14:06:58 +0200 Subject: [PATCH 0253/1164] Call HSET after FT.CREATE to avoid keyspace scan (#1706) --- tests/test_search.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_search.py b/tests/test_search.py index e07a61c977..75559d37f2 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -643,9 +643,6 @@ def test_alias(): index1 = getClient() index2 = getClient() - index1.hset("index1:lonestar", mapping={"name": "lonestar"}) - index2.hset("index2:yogurt", mapping={"name": "yogurt"}) - def1 = IndexDefinition(prefix=["index1:"]) def2 = IndexDefinition(prefix=["index2:"]) @@ -654,6 +651,9 @@ def test_alias(): ftindex1.create_index((TextField("name"),), definition=def1) ftindex2.create_index((TextField("name"),), definition=def2) + index1.hset("index1:lonestar", mapping={"name": "lonestar"}) + index2.hset("index2:yogurt", mapping={"name": "yogurt"}) + res = ftindex1.search("*").docs[0] assert "index1:lonestar" == res.id From 46f935186bc068df676dfd03faf91f35aa8fae4a Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 14 Nov 2021 14:14:00 +0200 Subject: [PATCH 0254/1164] FT.EXPLAINCLI intentionally raising NotImplementedError (#1705) --- redis/commands/search/commands.py | 4 ++++ tests/test_search.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py index 296fb258ac..0cee2ade4e 100644 --- a/redis/commands/search/commands.py +++ b/redis/commands/search/commands.py @@ -17,6 +17,7 @@ ADDHASH_CMD = "FT.ADDHASH" DROP_CMD = "FT.DROP" EXPLAIN_CMD = "FT.EXPLAIN" +EXPLAINCLI_CMD = "FT.EXPLAINCLI" DEL_CMD = "FT.DEL" AGGREGATE_CMD = "FT.AGGREGATE" CURSOR_CMD = "FT.CURSOR" @@ -376,6 +377,9 @@ def explain(self, query): args, query_text = self._mk_query_args(query) return self.execute_command(EXPLAIN_CMD, *args) + def explain_cli(self, query): # noqa + raise NotImplementedError("EXPLAINCLI will not be implemented.") + def aggregate(self, query): """ Issue an aggregation query diff --git a/tests/test_search.py b/tests/test_search.py index 75559d37f2..d1fc75fb9d 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -611,6 +611,12 @@ def test_explain(client): assert res +@pytest.mark.redismod +def test_explaincli(client): + with pytest.raises(NotImplementedError): + client.ft().explain_cli("foo") + + @pytest.mark.redismod def test_summarize(client): createIndex(client.ft()) From 38384931d26d8a1f32e67461d13feb38bd3e8f55 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 14 Nov 2021 14:49:55 +0200 Subject: [PATCH 0255/1164] Re-enabling read-the-docs (#1707) --- .readthedocs.yml | 13 +++ docs/conf.py | 16 ++-- docs/index.rst | 16 ++++ docs/make.bat | 190 ------------------------------------------ docs/requirements.txt | 2 + tox.ini | 3 +- 6 files changed, 41 insertions(+), 199 deletions(-) create mode 100644 .readthedocs.yml delete mode 100644 docs/make.bat create mode 100644 docs/requirements.txt diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000000..80b9738d82 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,13 @@ +version: 2 + +python: + install: + - requirements: ./docs/requirements.txt + +build: + os: ubuntu-20.04 + tools: + python: "3.9" + +sphinx: + configuration: docs/conf.py diff --git a/docs/conf.py b/docs/conf.py index dfdaf9ea57..ff37119467 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,16 +46,16 @@ # General information about the project. project = "redis-py" -copyright = "2016, Andy McCurdy" +copyright = "2021, Redis Inc." # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = "2.10.5" +version = "4.0.9" # The full version, including alpha/beta/rc tags. -release = "2.10.5" +release = "4.0.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -191,7 +191,7 @@ ("index", "redis-py.tex", "redis-py Documentation", - "Andy McCurdy", + "Redis Inc", "manual"), ] @@ -240,7 +240,7 @@ "index", "redis-py", "redis-py Documentation", - "Andy McCurdy", + "Redis Inc", "redis-py", "One line description of project.", "Miscellaneous", @@ -257,6 +257,6 @@ # texinfo_show_urls = 'footnote' epub_title = "redis-py" -epub_author = "Andy McCurdy" -epub_publisher = "Andy McCurdy" -epub_copyright = "2011, Andy McCurdy" +epub_author = "Redis Inc" +epub_publisher = "Redis Inc" +epub_copyright = "2021, Redis Inc" diff --git a/docs/index.rst b/docs/index.rst index bc1a4fac41..8af5385da3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,5 +22,21 @@ Contents: .. automodule:: redis :members: +.. automodule:: redis.backoff + :members: + +.. automodule:: redis.connection + :members: + +.. automodule:: redis.commands + :members: + +.. automodule:: redis.exceptions + :members: + +.. automodule:: redis.lock + :members: + .. automodule:: redis.sentinel :members: + diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index bb2ae4d3da..0000000000 --- a/docs/make.bat +++ /dev/null @@ -1,190 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\redis-py.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\redis-py.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -:end diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000000..2e1c4fbdce --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx<2 +docutils<0.18 diff --git a/tox.ini b/tox.ini index 1d4da0829b..6d4c65849b 100644 --- a/tox.ini +++ b/tox.ini @@ -127,7 +127,8 @@ exclude = .tox, .venv*, build, + docs/*, dist, docker, venv*, - whitelist.py \ No newline at end of file + whitelist.py From c02d7209be5aeefe27c7ac2e740dafc4d969ec47 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 15 Nov 2021 13:42:48 +0200 Subject: [PATCH 0256/1164] 4.0.0 (#1708) * 4.0.0 --- MANIFEST.in | 3 +-- RELEASE | 9 --------- docs/conf.py | 5 +++-- redis/__init__.py | 4 +++- 4 files changed, 7 insertions(+), 14 deletions(-) delete mode 100644 RELEASE diff --git a/MANIFEST.in b/MANIFEST.in index 7aaee12a1d..97fa305889 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,6 @@ -include CHANGES include INSTALL include LICENSE -include README.rst +include README.md exclude __pycache__ recursive-include tests * recursive-exclude tests *.pyc diff --git a/RELEASE b/RELEASE deleted file mode 100644 index f45b0bf9f6..0000000000 --- a/RELEASE +++ /dev/null @@ -1,9 +0,0 @@ -Release Process -=============== - -1. Make sure all tests pass. -2. Make sure CHANGES is up to date. -3. Update redis.__init__.__version__ and commit -4. git tag -5. git push --tag -6. rm dist/* && python setup.py sdist bdist_wheel && twine upload dist/* diff --git a/docs/conf.py b/docs/conf.py index ff37119467..f497e3da15 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,14 +46,15 @@ # General information about the project. project = "redis-py" -copyright = "2021, Redis Inc." +copyright = "2021, Redis Inc" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = "4.0.9" +version = "4.0" + # The full version, including alpha/beta/rc tags. release = "4.0.0" diff --git a/redis/__init__.py b/redis/__init__.py index c0dd690bc9..5d635431ea 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -37,7 +37,9 @@ def int_or_str(value): return value -__version__ = '4.0.0rc2' +__version__ = "4.0.0" + + VERSION = tuple(map(int_or_str, __version__.split('.'))) __all__ = [ From e74ed1942a746683ece792cb1670c544f599246f Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 17 Nov 2021 11:54:45 +0200 Subject: [PATCH 0257/1164] removing hiredis warning (#1721) --- redis/__init__.py | 2 +- redis/connection.py | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/redis/__init__.py b/redis/__init__.py index 5d635431ea..480ffd8992 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -37,7 +37,7 @@ def int_or_str(value): return value -__version__ = "4.0.0" +__version__ = "4.0.1" VERSION = tuple(map(int_or_str, __version__.split('.'))) diff --git a/redis/connection.py b/redis/connection.py index cb9acb4595..35e9491ba5 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -63,11 +63,6 @@ HIREDIS_SUPPORTS_ENCODING_ERRORS = \ hiredis_version >= LooseVersion('1.0.0') - if not HIREDIS_SUPPORTS_BYTE_BUFFER: - msg = ("redis-py works best with hiredis >= 0.1.4. You're running " - "hiredis %s. Please consider upgrading." % hiredis.__version__) - warnings.warn(msg) - HIREDIS_USE_BYTE_BUFFER = True # only use byte buffer if hiredis supports it if not HIREDIS_SUPPORTS_BYTE_BUFFER: From 4e9cc015e32ef305429ff9dfa200e28dd63e6663 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 17 Nov 2021 11:55:03 +0200 Subject: [PATCH 0258/1164] Removing command on initial connections (#1722) --- redis/client.py | 18 ------------------ redis/commands/redismodules.py | 13 ------------- tests/conftest.py | 3 ++- 3 files changed, 2 insertions(+), 32 deletions(-) diff --git a/redis/client.py b/redis/client.py index 753770e3e2..3230043a62 100755 --- a/redis/client.py +++ b/redis/client.py @@ -890,12 +890,6 @@ def __init__(self, host='localhost', port=6379, self.response_callbacks = CaseInsensitiveDict( self.__class__.RESPONSE_CALLBACKS) - # preload our class with the available redis commands - try: - self.__redis_commands__() - except RedisError: - pass - def __repr__(self): return "%s<%s>" % (type(self).__name__, repr(self.connection_pool)) @@ -927,18 +921,6 @@ def load_external_module(self, funcname, func, """ setattr(self, funcname, func) - def __redis_commands__(self): - """Store the list of available commands, for our redis instance.""" - cmds = getattr(self, '__commands__', None) - if cmds is not None: - return cmds - try: - cmds = [c[0].upper().decode() for c in self.command()] - except AttributeError: # if encoded - cmds = [c[0].upper() for c in self.command()] - self.__commands__ = cmds - return cmds - def pipeline(self, transaction=True, shard_hint=None): """ Return a new pipeline object that can queue multiple commands for diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py index b3cbee1d87..5f629fb5ea 100644 --- a/redis/commands/redismodules.py +++ b/redis/commands/redismodules.py @@ -1,5 +1,4 @@ from json import JSONEncoder, JSONDecoder -from redis.exceptions import ModuleError class RedisModuleCommands: @@ -10,10 +9,6 @@ class RedisModuleCommands: def json(self, encoder=JSONEncoder(), decoder=JSONDecoder()): """Access the json namespace, providing support for redis json. """ - if 'JSON.SET' not in self.__commands__: - raise ModuleError("redisjson is not loaded in redis. " - "For more information visit " - "https://redisjson.io/") from .json import JSON jj = JSON( @@ -25,10 +20,6 @@ def json(self, encoder=JSONEncoder(), decoder=JSONDecoder()): def ft(self, index_name="idx"): """Access the search namespace, providing support for redis search. """ - if 'FT.INFO' not in self.__commands__: - raise ModuleError("redisearch is not loaded in redis. " - "For more information visit " - "https://redisearch.io/") from .search import Search s = Search(client=self, index_name=index_name) @@ -38,10 +29,6 @@ def ts(self): """Access the timeseries namespace, providing support for redis timeseries data. """ - if 'TS.INFO' not in self.__commands__: - raise ModuleError("reditimeseries is not loaded in redis. " - "For more information visit " - "https://redistimeseries.io/") from .timeseries import TimeSeries s = TimeSeries(client=self) diff --git a/tests/conftest.py b/tests/conftest.py index 31d3fbd1a8..9504333354 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,7 +31,8 @@ def pytest_addoption(parser): def _get_info(redis_url): client = redis.Redis.from_url(redis_url) info = client.info() - if 'dping' in client.__commands__: + cmds = [c[0].upper().decode() for c in client.command()] + if 'dping' in cmds: info["enterprise"] = True else: info["enterprise"] = False From 791f482dcb320f48cf950c4b2c6047d1981a8f67 Mon Sep 17 00:00:00 2001 From: Alex Wu Date: Sat, 20 Nov 2021 23:47:44 -0800 Subject: [PATCH 0259/1164] Better removal of hiredis warning (#1726) Co-authored-by: Alex Wu --- redis/__init__.py | 2 +- redis/connection.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/redis/__init__.py b/redis/__init__.py index 480ffd8992..dc9b11a7b8 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -37,7 +37,7 @@ def int_or_str(value): return value -__version__ = "4.0.1" +__version__ = "4.0.2" VERSION = tuple(map(int_or_str, __version__.split('.'))) diff --git a/redis/connection.py b/redis/connection.py index 35e9491ba5..e01742d70e 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -9,7 +9,6 @@ import os import socket import threading -import warnings import weakref from redis.exceptions import ( @@ -67,9 +66,6 @@ # only use byte buffer if hiredis supports it if not HIREDIS_SUPPORTS_BYTE_BUFFER: HIREDIS_USE_BYTE_BUFFER = False -else: - msg = "redis-py works best with hiredis. Please consider installing" - warnings.warn(msg) SYM_STAR = b'*' SYM_DOLLAR = b'$' From d2b233384458869270352b8c99ca682ae480da5f Mon Sep 17 00:00:00 2001 From: Sam Culley Date: Sun, 21 Nov 2021 07:47:59 +0000 Subject: [PATCH 0260/1164] fix: adding sentinelcommands to redis client (#1723) Co-authored-by: Sam Culley --- redis/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/redis/client.py b/redis/client.py index 3230043a62..dc6693d111 100755 --- a/redis/client.py +++ b/redis/client.py @@ -6,7 +6,8 @@ import threading import time import warnings -from redis.commands import CoreCommands, RedisModuleCommands, list_or_args +from redis.commands import (CoreCommands, RedisModuleCommands, + SentinelCommands, list_or_args) from redis.connection import (ConnectionPool, UnixDomainSocketConnection, SSLConnection) from redis.lock import Lock @@ -606,7 +607,7 @@ def parse_set_result(response, **options): return response and str_if_bytes(response) == 'OK' -class Redis(RedisModuleCommands, CoreCommands, object): +class Redis(RedisModuleCommands, CoreCommands, SentinelCommands, object): """ Implementation of the Redis protocol. From 64791a54f4b2c28b6a61920a23df9e1f614e6983 Mon Sep 17 00:00:00 2001 From: Carlosbogo <84228424+Carlosbogo@users.noreply.github.com> Date: Sun, 21 Nov 2021 14:44:18 +0100 Subject: [PATCH 0261/1164] Adding links to redis documents in function calls (#1719) --- redis/commands/core.py | 762 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 683 insertions(+), 79 deletions(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index 516e7d9c83..5fad0b65f1 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -28,12 +28,18 @@ def acl_cat(self, category=None): If ``category`` is not supplied, returns a list of all categories. If ``category`` is supplied, returns a list of all commands within that category. + + For more information check https://redis.io/commands/acl-cat """ pieces = [category] if category else [] return self.execute_command('ACL CAT', *pieces) def acl_deluser(self, *username): - "Delete the ACL for the specified ``username``s" + """ + Delete the ACL for the specified ``username``s + + For more information check https://redis.io/commands/acl-deluser + """ return self.execute_command('ACL DELUSER', *username) def acl_genpass(self, bits=None): @@ -58,17 +64,25 @@ def acl_getuser(self, username): Get the ACL details for the specified ``username``. If ``username`` does not exist, return None + + For more information check https://redis.io/commands/acl-getuser """ return self.execute_command('ACL GETUSER', username) def acl_help(self): """The ACL HELP command returns helpful text describing the different subcommands. + + For more information check https://redis.io/commands/acl-help """ return self.execute_command('ACL HELP') def acl_list(self): - "Return a list of all ACLs on the server" + """ + Return a list of all ACLs on the server + + For more information check https://redis.io/commands/acl-list + """ return self.execute_command('ACL LIST') def acl_log(self, count=None): @@ -76,6 +90,8 @@ def acl_log(self, count=None): Get ACL logs as a list. :param int count: Get logs[0:count]. :rtype: List. + + For more information check https://redis.io/commands/acl-log """ args = [] if count is not None: @@ -90,6 +106,8 @@ def acl_log_reset(self): """ Reset ACL logs. :rtype: Boolean. + + For more information check https://redis.io/commands/acl-log """ args = [b'RESET'] return self.execute_command('ACL LOG', *args) @@ -100,6 +118,8 @@ def acl_load(self): Note that the server must be configured with the ``aclfile`` directive to be able to load ACL rules from an aclfile. + + For more information check https://redis.io/commands/acl-load """ return self.execute_command('ACL LOAD') @@ -109,6 +129,8 @@ def acl_save(self): Note that the server must be configured with the ``aclfile`` directive to be able to save ACL rules to an aclfile. + + For more information check https://redis.io/commands/acl-save """ return self.execute_command('ACL SAVE') @@ -174,6 +196,8 @@ def acl_setuser(self, username, enabled=False, nopass=False, 'hashed_passwords'. If this is False, the user's existing passwords and 'nopass' status will be kept and any new specified passwords or hashed_passwords will be applied on top. + + For more information check https://redis.io/commands/acl-setuser """ encoder = self.connection_pool.get_encoder() pieces = [username] @@ -260,21 +284,32 @@ def acl_setuser(self, username, enabled=False, nopass=False, return self.execute_command('ACL SETUSER', *pieces) def acl_users(self): - "Returns a list of all registered users on the server." + """Returns a list of all registered users on the server. + + For more information check https://redis.io/commands/acl-users + """ return self.execute_command('ACL USERS') def acl_whoami(self): - "Get the username for the current connection" + """Get the username for the current connection + + For more information check https://redis.io/commands/acl-whoami + """ return self.execute_command('ACL WHOAMI') def bgrewriteaof(self): - "Tell the Redis server to rewrite the AOF file from data in memory." + """Tell the Redis server to rewrite the AOF file from data in memory. + + For more information check https://redis.io/commands/bgrewriteaof + """ return self.execute_command('BGREWRITEAOF') def bgsave(self, schedule=True): """ Tell the Redis server to save its data to disk. Unlike save(), this method is asynchronous and returns immediately. + + For more information check https://redis.io/commands/bgsave """ pieces = [] if schedule: @@ -282,7 +317,10 @@ def bgsave(self, schedule=True): return self.execute_command('BGSAVE', *pieces) def client_kill(self, address): - "Disconnects the client at ``address`` (ip:port)" + """Disconnects the client at ``address`` (ip:port) + + For more information check https://redis.io/commands/client-kill + """ return self.execute_command('CLIENT KILL', address) def client_kill_filter(self, _id=None, _type=None, addr=None, @@ -330,6 +368,8 @@ def client_info(self): """ Returns information and statistics about the current client connection. + + For more information check https://redis.io/commands/client-info """ return self.execute_command('CLIENT INFO') @@ -340,8 +380,9 @@ def client_list(self, _type=None, client_id=[]): :param _type: optional. one of the client types (normal, master, replica, pubsub) :param client_id: optional. a list of client ids + + For more information check https://redis.io/commands/client-list """ - "Returns a list of currently connected clients" args = [] if _type is not None: client_types = ('normal', 'master', 'replica', 'pubsub') @@ -358,11 +399,16 @@ def client_list(self, _type=None, client_id=[]): return self.execute_command('CLIENT LIST', *args) def client_getname(self): - """Returns the current connection name""" + """ + Returns the current connection name + + For more information check https://redis.io/commands/client-getname + """ return self.execute_command('CLIENT GETNAME') def client_getredir(self): - """Returns the ID (an integer) of the client to whom we are + """ + Returns the ID (an integer) of the client to whom we are redirecting tracking notifications. see: https://redis.io/commands/client-getredir @@ -370,7 +416,8 @@ def client_getredir(self): return self.execute_command('CLIENT GETREDIR') def client_reply(self, reply): - """Enable and disable redis server replies. + """ + Enable and disable redis server replies. ``reply`` Must be ON OFF or SKIP, ON - The default most with server replies to commands OFF - Disable server responses to commands @@ -381,6 +428,7 @@ def client_reply(self, reply): TimeoutError. The test_client_reply unit test illustrates this, and conftest.py has a client with a timeout. + See https://redis.io/commands/client-reply """ replies = ['ON', 'OFF', 'SKIP'] @@ -389,19 +437,28 @@ def client_reply(self, reply): return self.execute_command("CLIENT REPLY", reply) def client_id(self): - """Returns the current connection id""" + """ + Returns the current connection id + + For more information check https://redis.io/commands/client-id + """ return self.execute_command('CLIENT ID') def client_trackinginfo(self): """ Returns the information about the current client connection's use of the server assisted client side cache. + See https://redis.io/commands/client-trackinginfo """ return self.execute_command('CLIENT TRACKINGINFO') def client_setname(self, name): - "Sets the current connection name" + """ + Sets the current connection name + + For more information check https://redis.io/commands/client-setname + """ return self.execute_command('CLIENT SETNAME', name) def client_unblock(self, client_id, error=False): @@ -410,6 +467,8 @@ def client_unblock(self, client_id, error=False): If ``error`` is True, unblocks the client with a special error message. If ``error`` is False (default), the client is unblocked using the regular timeout mechanism. + + For more information check https://redis.io/commands/client-unblock """ args = ['CLIENT UNBLOCK', int(client_id)] if error: @@ -420,6 +479,8 @@ def client_pause(self, timeout): """ Suspend all the Redis clients for the specified amount of time :param timeout: milliseconds to pause clients + + For more information check https://redis.io/commands/client-pause """ if not isinstance(timeout, int): raise DataError("CLIENT PAUSE timeout must be an integer") @@ -428,54 +489,89 @@ def client_pause(self, timeout): def client_unpause(self): """ Unpause all redis clients + + For more information check https://redis.io/commands/client-unpause """ return self.execute_command('CLIENT UNPAUSE') def readwrite(self): """ Disables read queries for a connection to a Redis Cluster slave node. + + For more information check https://redis.io/commands/readwrite """ return self.execute_command('READWRITE') def readonly(self): """ Enables read queries for a connection to a Redis Cluster replica node. + + For more information check https://redis.io/commands/readonly """ return self.execute_command('READONLY') def config_get(self, pattern="*"): - """Return a dictionary of configuration based on the ``pattern``""" + """ + Return a dictionary of configuration based on the ``pattern`` + + For more information check https://redis.io/commands/config-get + """ return self.execute_command('CONFIG GET', pattern) def config_set(self, name, value): - "Set config item ``name`` with ``value``" + """Set config item ``name`` with ``value`` + + For more information check https://redis.io/commands/config-set + """ return self.execute_command('CONFIG SET', name, value) def config_resetstat(self): - """Reset runtime statistics""" + """ + Reset runtime statistics + + For more information check https://redis.io/commands/config-resetstat + """ return self.execute_command('CONFIG RESETSTAT') def config_rewrite(self): """ Rewrite config file with the minimal change to reflect running config. + + For more information check https://redis.io/commands/config-rewrite """ return self.execute_command('CONFIG REWRITE') def dbsize(self): - """Returns the number of keys in the current database""" + """ + Returns the number of keys in the current database + + For more information check https://redis.io/commands/dbsize + """ return self.execute_command('DBSIZE') def debug_object(self, key): - """Returns version specific meta information about a given key""" + """ + Returns version specific meta information about a given key + + For more information check https://redis.io/commands/debug-object + """ return self.execute_command('DEBUG OBJECT', key) def debug_segfault(self): raise NotImplementedError( - "DEBUG SEGFAULT is intentionally not implemented in the client." + """ + DEBUG SEGFAULT is intentionally not implemented in the client. + + For more information check https://redis.io/commands/debug-segfault + """ ) def echo(self, value): - """Echo the string back from the server""" + """ + Echo the string back from the server + + For more information check https://redis.io/commands/echo + """ return self.execute_command('ECHO', value) def flushall(self, asynchronous=False): @@ -484,6 +580,8 @@ def flushall(self, asynchronous=False): ``asynchronous`` indicates whether the operation is executed asynchronously by the server. + + For more information check https://redis.io/commands/flushall """ args = [] if asynchronous: @@ -496,6 +594,8 @@ def flushdb(self, asynchronous=False): ``asynchronous`` indicates whether the operation is executed asynchronously by the server. + + For more information check https://redis.io/commands/flushdb """ args = [] if asynchronous: @@ -503,7 +603,11 @@ def flushdb(self, asynchronous=False): return self.execute_command('FLUSHDB', *args) def swapdb(self, first, second): - "Swap two databases" + """ + Swap two databases + + For more information check https://redis.io/commands/swapdb + """ return self.execute_command('SWAPDB', first, second) def info(self, section=None): @@ -515,6 +619,8 @@ def info(self, section=None): The section option is not supported by older versions of Redis Server, and will generate ResponseError + + For more information check https://redis.io/commands/info """ if section is None: return self.execute_command('INFO') @@ -525,12 +631,15 @@ def lastsave(self): """ Return a Python datetime object representing the last time the Redis database was saved to disk + + For more information check https://redis.io/commands/lastsave """ return self.execute_command('LASTSAVE') def lolwut(self, *version_numbers): """ Get the Redis version and a piece of generative computer art + See: https://redis.io/commands/lolwut """ if version_numbers: @@ -556,6 +665,8 @@ def migrate(self, host, port, keys, destination_db, timeout, If ``auth`` is specified, authenticate to the destination server with the password provided. + + For more information check https://redis.io/commands/migrate """ keys = list_or_args(keys, []) if not keys: @@ -574,25 +685,43 @@ def migrate(self, host, port, keys, destination_db, timeout, timeout, *pieces) def object(self, infotype, key): - """Return the encoding, idletime, or refcount about the key""" + """ + Return the encoding, idletime, or refcount about the key + """ return self.execute_command('OBJECT', infotype, key, infotype=infotype) def memory_doctor(self): raise NotImplementedError( - "MEMORY DOCTOR is intentionally not implemented in the client." + """ + MEMORY DOCTOR is intentionally not implemented in the client. + + For more information check https://redis.io/commands/memory-doctor + """ ) def memory_help(self): raise NotImplementedError( - "MEMORY HELP is intentionally not implemented in the client." + """ + MEMORY HELP is intentionally not implemented in the client. + + For more information check https://redis.io/commands/memory-help + """ ) def memory_stats(self): - """Return a dictionary of memory stats""" + """ + Return a dictionary of memory stats + + For more information check https://redis.io/commands/memory-stats + """ return self.execute_command('MEMORY STATS') def memory_malloc_stats(self): - """Return an internal statistics report from the memory allocator.""" + """ + Return an internal statistics report from the memory allocator. + + See: https://redis.io/commands/memory-malloc-stats + """ return self.execute_command('MEMORY MALLOC-STATS') def memory_usage(self, key, samples=None): @@ -603,6 +732,8 @@ def memory_usage(self, key, samples=None): For nested data structures, ``samples`` is the number of elements to sample. If left unspecified, the server's default is 5. Use 0 to sample all elements. + + For more information check https://redis.io/commands/memory-usage """ args = [] if isinstance(samples, int): @@ -610,17 +741,26 @@ def memory_usage(self, key, samples=None): return self.execute_command('MEMORY USAGE', key, *args) def memory_purge(self): - """Attempts to purge dirty pages for reclamation by allocator""" + """ + Attempts to purge dirty pages for reclamation by allocator + + For more information check https://redis.io/commands/memory-purge + """ return self.execute_command('MEMORY PURGE') def ping(self): - """Ping the Redis server""" + """ + Ping the Redis server + + For more information check https://redis.io/commands/ping + """ return self.execute_command('PING') def quit(self): """ Ask the server to close the connection. - https://redis.io/commands/quit + + For more information check https://redis.io/commands/quit """ return self.execute_command('QUIT') @@ -628,6 +768,8 @@ def save(self): """ Tell the Redis server to save its data to disk, blocking until the save is complete + + For more information check https://redis.io/commands/save """ return self.execute_command('SAVE') @@ -637,6 +779,8 @@ def shutdown(self, save=False, nosave=False): a data flush will be attempted even if there is no persistence configured. If the "nosave" option is set, no data flush will be attempted. The "save" and "nosave" options cannot both be set. + + For more information check https://redis.io/commands/shutdown """ if save and nosave: raise DataError('SHUTDOWN save and nosave cannot both be set') @@ -657,6 +801,8 @@ def slaveof(self, host=None, port=None): Set the server to be a replicated slave of the instance identified by the ``host`` and ``port``. If called without arguments, the instance is promoted to a master instead. + + For more information check https://redis.io/commands/slaveof """ if host is None and port is None: return self.execute_command('SLAVEOF', b'NO', b'ONE') @@ -666,6 +812,8 @@ def slowlog_get(self, num=None): """ Get the entries from the slowlog. If ``num`` is specified, get the most recent ``num`` items. + + For more information check https://redis.io/commands/slowlog-get """ args = ['SLOWLOG GET'] if num is not None: @@ -675,17 +823,27 @@ def slowlog_get(self, num=None): return self.execute_command(*args, decode_responses=decode_responses) def slowlog_len(self): - "Get the number of items in the slowlog" + """ + Get the number of items in the slowlog + + For more information check https://redis.io/commands/slowlog-len + """ return self.execute_command('SLOWLOG LEN') def slowlog_reset(self): - "Remove all items in the slowlog" + """ + Remove all items in the slowlog + + For more information check https://redis.io/commands/slowlog-reset + """ return self.execute_command('SLOWLOG RESET') def time(self): """ Returns the server time as a 2-item tuple of ints: (seconds since epoch, microseconds into this second). + + For more information check https://redis.io/commands/time """ return self.execute_command('TIME') @@ -695,6 +853,8 @@ def wait(self, num_replicas, timeout): That returns the number of replicas that processed the query when we finally have at least ``num_replicas``, or when the ``timeout`` was reached. + + For more information check https://redis.io/commands/wait """ return self.execute_command('WAIT', num_replicas, timeout) @@ -704,6 +864,8 @@ def append(self, key, value): Appends the string ``value`` to the value at ``key``. If ``key`` doesn't already exist, create it with a value of ``value``. Returns the new length of the value at ``key``. + + For more information check https://redis.io/commands/append """ return self.execute_command('APPEND', key, value) @@ -711,6 +873,8 @@ def bitcount(self, key, start=None, end=None): """ Returns the count of set bits in the value of ``key``. Optional ``start`` and ``end`` parameters indicate which bytes to consider + + For more information check https://redis.io/commands/bitcount """ params = [key] if start is not None and end is not None: @@ -725,6 +889,8 @@ def bitfield(self, key, default_overflow=None): """ Return a BitFieldOperation instance to conveniently construct one or more bitfield operations on ``key``. + + For more information check https://redis.io/commands/bitfield """ return BitFieldOperation(self, key, default_overflow=default_overflow) @@ -732,6 +898,8 @@ def bitop(self, operation, dest, *keys): """ Perform a bitwise operation using ``operation`` between ``keys`` and store the result in ``dest``. + + For more information check https://redis.io/commands/bitop """ return self.execute_command('BITOP', operation, dest, *keys) @@ -741,6 +909,8 @@ def bitpos(self, key, bit, start=None, end=None): ``start`` and ``end`` defines search range. The range is interpreted as a range of bytes and not a range of bits, so start=0 and end=2 means to look at the first three bytes. + + For more information check https://redis.io/commands/bitpos """ if bit not in (0, 1): raise DataError('bit must be 0 or 1') @@ -765,6 +935,8 @@ def copy(self, source, destination, destination_db=None, replace=False): ``replace`` whether the ``destination`` key should be removed before copying the value to it. By default, the value is not copied if the ``destination`` key already exists. + + For more information check https://redis.io/commands/copy """ params = [source, destination] if destination_db is not None: @@ -777,6 +949,8 @@ def decr(self, name, amount=1): """ Decrements the value of ``key`` by ``amount``. If no key exists, the value will be initialized as 0 - ``amount`` + + For more information check https://redis.io/commands/decr """ # An alias for ``decr()``, because it is already implemented # as DECRBY redis command. @@ -786,11 +960,15 @@ def decrby(self, name, amount=1): """ Decrements the value of ``key`` by ``amount``. If no key exists, the value will be initialized as 0 - ``amount`` + + For more information check https://redis.io/commands/decrby """ return self.execute_command('DECRBY', name, amount) def delete(self, *names): - "Delete one or more keys specified by ``names``" + """ + Delete one or more keys specified by ``names`` + """ return self.execute_command('DEL', *names) def __delitem__(self, name): @@ -800,11 +978,17 @@ def dump(self, name): """ Return a serialized version of the value stored at the specified key. If key does not exist a nil bulk reply is returned. + + For more information check https://redis.io/commands/dump """ return self.execute_command('DUMP', name) def exists(self, *names): - "Returns the number of ``names`` that exist" + """ + Returns the number of ``names`` that exist + + For more information check https://redis.io/commands/exists + """ return self.execute_command('EXISTS', *names) __contains__ = exists @@ -812,6 +996,8 @@ def expire(self, name, time): """ Set an expire flag on key ``name`` for ``time`` seconds. ``time`` can be represented by an integer or a Python timedelta object. + + For more information check https://redis.io/commands/expire """ if isinstance(time, datetime.timedelta): time = int(time.total_seconds()) @@ -821,6 +1007,8 @@ def expireat(self, name, when): """ Set an expire flag on key ``name``. ``when`` can be represented as an integer indicating unix time or a Python datetime object. + + For more information check https://redis.io/commands/expireat """ if isinstance(when, datetime.datetime): when = int(time.mktime(when.timetuple())) @@ -829,6 +1017,8 @@ def expireat(self, name, when): def get(self, name): """ Return the value at key ``name``, or None if the key doesn't exist + + For more information check https://redis.io/commands/get """ return self.execute_command('GET', name) @@ -838,6 +1028,8 @@ def getdel(self, name): is similar to GET, except for the fact that it also deletes the key on success (if and only if the key's value type is a string). + + For more information check https://redis.io/commands/getdel """ return self.execute_command('GETDEL', name) @@ -860,6 +1052,8 @@ def getex(self, name, specified in unix time. ``persist`` remove the time to live associated with ``name``. + + For more information check https://redis.io/commands/getex """ opset = set([ex, px, exat, pxat]) @@ -908,13 +1102,19 @@ def __getitem__(self, name): raise KeyError(name) def getbit(self, name, offset): - "Returns a boolean indicating the value of ``offset`` in ``name``" + """ + Returns a boolean indicating the value of ``offset`` in ``name`` + + For more information check https://redis.io/commands/getbit + """ return self.execute_command('GETBIT', name, offset) def getrange(self, key, start, end): """ Returns the substring of the string value stored at ``key``, determined by the offsets ``start`` and ``end`` (both are inclusive) + + For more information check https://redis.io/commands/getrange """ return self.execute_command('GETRANGE', key, start, end) @@ -925,6 +1125,8 @@ def getset(self, name, value): As per Redis 6.2, GETSET is considered deprecated. Please use SET with GET parameter in new code. + + For more information check https://redis.io/commands/getset """ return self.execute_command('GETSET', name, value) @@ -932,6 +1134,8 @@ def incr(self, name, amount=1): """ Increments the value of ``key`` by ``amount``. If no key exists, the value will be initialized as ``amount`` + + For more information check https://redis.io/commands/incr """ return self.incrby(name, amount) @@ -939,6 +1143,8 @@ def incrby(self, name, amount=1): """ Increments the value of ``key`` by ``amount``. If no key exists, the value will be initialized as ``amount`` + + For more information check https://redis.io/commands/incrby """ # An alias for ``incr()``, because it is already implemented # as INCRBY redis command. @@ -948,11 +1154,17 @@ def incrbyfloat(self, name, amount=1.0): """ Increments the value at key ``name`` by floating ``amount``. If no key exists, the value will be initialized as ``amount`` + + For more information check https://redis.io/commands/incrbyfloat """ return self.execute_command('INCRBYFLOAT', name, amount) def keys(self, pattern='*'): - "Returns a list of keys matching ``pattern``" + """ + Returns a list of keys matching ``pattern`` + + For more information check https://redis.io/commands/keys + """ return self.execute_command('KEYS', pattern) def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): @@ -960,6 +1172,8 @@ def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): Atomically returns and removes the first/last element of a list, pushing it as the first/last element on the destination list. Returns the element being popped and pushed. + + For more information check https://redis.io/commands/lmov """ params = [first_list, second_list, src, dest] return self.execute_command("LMOVE", *params) @@ -968,6 +1182,8 @@ def blmove(self, first_list, second_list, timeout, src="LEFT", dest="RIGHT"): """ Blocking version of lmove. + + For more information check https://redis.io/commands/blmove """ params = [first_list, second_list, src, dest, timeout] return self.execute_command("BLMOVE", *params) @@ -975,6 +1191,8 @@ def blmove(self, first_list, second_list, timeout, def mget(self, keys, *args): """ Returns a list of values ordered identically to ``keys`` + + For more information check https://redis.io/commands/mget """ from redis.client import EMPTY_RESPONSE args = list_or_args(keys, args) @@ -988,6 +1206,8 @@ def mset(self, mapping): Sets key/values based on a mapping. Mapping is a dictionary of key/value pairs. Both keys and values should be strings or types that can be cast to a string via str(). + + For more information check https://redis.io/commands/mset """ items = [] for pair in mapping.items(): @@ -1000,6 +1220,8 @@ def msetnx(self, mapping): Mapping is a dictionary of key/value pairs. Both keys and values should be strings or types that can be cast to a string via str(). Returns a boolean indicating if the operation was successful. + + For more information check https://redis.io/commands/msetnx """ items = [] for pair in mapping.items(): @@ -1007,11 +1229,19 @@ def msetnx(self, mapping): return self.execute_command('MSETNX', *items) def move(self, name, db): - "Moves the key ``name`` to a different Redis database ``db``" + """ + Moves the key ``name`` to a different Redis database ``db`` + + For more information check https://redis.io/commands/move + """ return self.execute_command('MOVE', name, db) def persist(self, name): - "Removes an expiration on ``name``" + """ + Removes an expiration on ``name`` + + For more information check https://redis.io/commands/persist + """ return self.execute_command('PERSIST', name) def pexpire(self, name, time): @@ -1019,6 +1249,8 @@ def pexpire(self, name, time): Set an expire flag on key ``name`` for ``time`` milliseconds. ``time`` can be represented by an integer or a Python timedelta object. + + For more information check https://redis.io/commands/pexpire """ if isinstance(time, datetime.timedelta): time = int(time.total_seconds() * 1000) @@ -1029,6 +1261,8 @@ def pexpireat(self, name, when): Set an expire flag on key ``name``. ``when`` can be represented as an integer representing unix time in milliseconds (unix time * 1000) or a Python datetime object. + + For more information check https://redis.io/commands/pexpireat """ if isinstance(when, datetime.datetime): ms = int(when.microsecond / 1000) @@ -1040,13 +1274,19 @@ def psetex(self, name, time_ms, value): Set the value of key ``name`` to ``value`` that expires in ``time_ms`` milliseconds. ``time_ms`` can be represented by an integer or a Python timedelta object + + For more information check https://redis.io/commands/psetex """ if isinstance(time_ms, datetime.timedelta): time_ms = int(time_ms.total_seconds() * 1000) return self.execute_command('PSETEX', name, time_ms, value) def pttl(self, name): - "Returns the number of milliseconds until the key ``name`` will expire" + """ + Returns the number of milliseconds until the key ``name`` will expire + + For more information check https://redis.io/commands/pttl + """ return self.execute_command('PTTL', name) def hrandfield(self, key, count=None, withvalues=False): @@ -1060,6 +1300,8 @@ def hrandfield(self, key, count=None, withvalues=False): specified count. withvalues: The optional WITHVALUES modifier changes the reply so it includes the respective values of the randomly selected hash fields. + + For more information check https://redis.io/commands/hrandfield """ params = [] if count is not None: @@ -1070,17 +1312,27 @@ def hrandfield(self, key, count=None, withvalues=False): return self.execute_command("HRANDFIELD", key, *params) def randomkey(self): - """Returns the name of a random key""" + """ + Returns the name of a random key + + For more information check https://redis.io/commands/randomkey + """ return self.execute_command('RANDOMKEY') def rename(self, src, dst): """ Rename key ``src`` to ``dst`` + + For more information check https://redis.io/commands/rename """ return self.execute_command('RENAME', src, dst) def renamenx(self, src, dst): - """Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist""" + """ + Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist + + For more information check https://redis.io/commands/renamenx + """ return self.execute_command('RENAMENX', src, dst) def restore(self, name, ttl, value, replace=False, absttl=False, @@ -1101,6 +1353,8 @@ def restore(self, name, ttl, value, replace=False, absttl=False, ``frequency`` Used for eviction, this is the frequency counter of the object stored at the key, prior to execution. + + For more information check https://redis.io/commands/restore """ params = [name, ttl, value] if replace: @@ -1151,6 +1405,8 @@ def set(self, name, value, ``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds, specified in unix time. + + For more information check https://redis.io/commands/set """ pieces = [name, value] options = {} @@ -1203,6 +1459,8 @@ def setbit(self, name, offset, value): """ Flag the ``offset`` in ``name`` as ``value``. Returns a boolean indicating the previous value of ``offset``. + + For more information check https://redis.io/commands/setbit """ value = value and 1 or 0 return self.execute_command('SETBIT', name, offset, value) @@ -1212,13 +1470,19 @@ def setex(self, name, time, value): Set the value of key ``name`` to ``value`` that expires in ``time`` seconds. ``time`` can be represented by an integer or a Python timedelta object. + + For more information check https://redis.io/commands/setex """ if isinstance(time, datetime.timedelta): time = int(time.total_seconds()) return self.execute_command('SETEX', name, time, value) def setnx(self, name, value): - "Set the value of key ``name`` to ``value`` if key doesn't exist" + """ + Set the value of key ``name`` to ``value`` if key doesn't exist + + For more information check https://redis.io/commands/setnx + """ return self.execute_command('SETNX', name, value) def setrange(self, name, offset, value): @@ -1231,6 +1495,8 @@ def setrange(self, name, offset, value): of what's being injected. Returns the length of the new string. + + For more information check https://redis.io/commands/setrange """ return self.execute_command('SETRANGE', name, offset, value) @@ -1252,6 +1518,8 @@ def stralgo(self, algo, value1, value2, specific_argument='strings', minimal length. Can be provided only when ``idx`` set to True. ``withmatchlen`` Returns the matches with the len of the match. Can be provided only when ``idx`` set to True. + + For more information check https://redis.io/commands/stralgo """ # check validity supported_algo = ['LCS'] @@ -1282,7 +1550,11 @@ def stralgo(self, algo, value1, value2, specific_argument='strings', withmatchlen=withmatchlen) def strlen(self, name): - "Return the number of bytes stored in the value of ``name``" + """ + Return the number of bytes stored in the value of ``name`` + + For more information check https://redis.io/commands/strlen + """ return self.execute_command('STRLEN', name) def substr(self, name, start, end=-1): @@ -1296,32 +1568,50 @@ def touch(self, *args): """ Alters the last access time of a key(s) ``*args``. A key is ignored if it does not exist. + + For more information check https://redis.io/commands/touch """ return self.execute_command('TOUCH', *args) def ttl(self, name): - "Returns the number of seconds until the key ``name`` will expire" + """ + Returns the number of seconds until the key ``name`` will expire + + For more information check https://redis.io/commands/ttl + """ return self.execute_command('TTL', name) def type(self, name): - "Returns the type of key ``name``" + """ + Returns the type of key ``name`` + + For more information check https://redis.io/commands/type + """ return self.execute_command('TYPE', name) def watch(self, *names): """ Watches the values at keys ``names``, or None if the key doesn't exist + + For more information check https://redis.io/commands/type """ warnings.warn(DeprecationWarning('Call WATCH from a Pipeline object')) def unwatch(self): """ Unwatches the value at key ``name``, or None of the key doesn't exist + + For more information check https://redis.io/commands/unwatch """ warnings.warn( DeprecationWarning('Call UNWATCH from a Pipeline object')) def unlink(self, *names): - "Unlink one or more keys specified by ``names``" + """ + Unlink one or more keys specified by ``names`` + + For more information check https://redis.io/commands/unlink + """ return self.execute_command('UNLINK', *names) # LIST COMMANDS @@ -1335,6 +1625,8 @@ def blpop(self, keys, timeout=0): of the lists. If timeout is 0, then block indefinitely. + + For more information check https://redis.io/commands/blpop """ if timeout is None: timeout = 0 @@ -1352,6 +1644,8 @@ def brpop(self, keys, timeout=0): of the lists. If timeout is 0, then block indefinitely. + + For more information check https://redis.io/commands/brpop """ if timeout is None: timeout = 0 @@ -1367,6 +1661,8 @@ def brpoplpush(self, src, dst, timeout=0): This command blocks until a value is in ``src`` or until ``timeout`` seconds elapse, whichever is first. A ``timeout`` value of 0 blocks forever. + + For more information check https://redis.io/commands/brpoplpush """ if timeout is None: timeout = 0 @@ -1378,6 +1674,8 @@ def lindex(self, name, index): Negative indexes are supported and will return an item at the end of the list + + For more information check https://redis.io/commands/lindex """ return self.execute_command('LINDEX', name, index) @@ -1388,11 +1686,17 @@ def linsert(self, name, where, refvalue, value): Returns the new length of the list on success or -1 if ``refvalue`` is not in the list. + + For more information check https://redis.io/commands/linsert """ return self.execute_command('LINSERT', name, where, refvalue, value) def llen(self, name): - "Return the length of the list ``name``" + """ + Return the length of the list ``name`` + + For more information check https://redis.io/commands/llen + """ return self.execute_command('LLEN', name) def lpop(self, name, count=None): @@ -1402,6 +1706,8 @@ def lpop(self, name, count=None): By default, the command pops a single element from the beginning of the list. When provided with the optional ``count`` argument, the reply will consist of up to count elements, depending on the list's length. + + For more information check https://redis.io/commands/lpop """ if count is not None: return self.execute_command('LPOP', name, count) @@ -1409,11 +1715,19 @@ def lpop(self, name, count=None): return self.execute_command('LPOP', name) def lpush(self, name, *values): - "Push ``values`` onto the head of the list ``name``" + """ + Push ``values`` onto the head of the list ``name`` + + For more information check https://redis.io/commands/lpush + """ return self.execute_command('LPUSH', name, *values) def lpushx(self, name, *values): - "Push ``value`` onto the head of the list ``name`` if ``name`` exists" + """ + Push ``value`` onto the head of the list ``name`` if ``name`` exists + + For more information check https://redis.io/commands/lpushx + """ return self.execute_command('LPUSHX', name, *values) def lrange(self, name, start, end): @@ -1423,6 +1737,8 @@ def lrange(self, name, start, end): ``start`` and ``end`` can be negative numbers just like Python slicing notation + + For more information check https://redis.io/commands/lrange """ return self.execute_command('LRANGE', name, start, end) @@ -1435,11 +1751,17 @@ def lrem(self, name, count, value): count > 0: Remove elements equal to value moving from head to tail. count < 0: Remove elements equal to value moving from tail to head. count = 0: Remove all elements equal to value. + + For more information check https://redis.io/commands/lrem """ return self.execute_command('LREM', name, count, value) def lset(self, name, index, value): - "Set ``position`` of list ``name`` to ``value``" + """ + Set ``position`` of list ``name`` to ``value`` + + For more information check https://redis.io/commands/lset + """ return self.execute_command('LSET', name, index, value) def ltrim(self, name, start, end): @@ -1449,6 +1771,8 @@ def ltrim(self, name, start, end): ``start`` and ``end`` can be negative numbers just like Python slicing notation + + For more information check https://redis.io/commands/ltrim """ return self.execute_command('LTRIM', name, start, end) @@ -1459,6 +1783,8 @@ def rpop(self, name, count=None): By default, the command pops a single element from the end of the list. When provided with the optional ``count`` argument, the reply will consist of up to count elements, depending on the list's length. + + For more information check https://redis.io/commands/rpop """ if count is not None: return self.execute_command('RPOP', name, count) @@ -1469,15 +1795,25 @@ def rpoplpush(self, src, dst): """ RPOP a value off of the ``src`` list and atomically LPUSH it on to the ``dst`` list. Returns the value. + + For more information check https://redis.io/commands/rpoplpush """ return self.execute_command('RPOPLPUSH', src, dst) def rpush(self, name, *values): - "Push ``values`` onto the tail of the list ``name``" + """ + Push ``values`` onto the tail of the list ``name`` + + For more information check https://redis.io/commands/rpush + """ return self.execute_command('RPUSH', name, *values) def rpushx(self, name, value): - "Push ``value`` onto the tail of the list ``name`` if ``name`` exists" + """ + Push ``value`` onto the tail of the list ``name`` if ``name`` exists + + For more information check https://redis.io/commands/rpushx + """ return self.execute_command('RPUSHX', name, value) def lpos(self, name, value, rank=None, count=None, maxlen=None): @@ -1503,6 +1839,8 @@ def lpos(self, name, value, rank=None, count=None, maxlen=None): elements to scan. A ``maxlen`` of 1000 will only return the position(s) of items within the first 1000 entries in the list. A ``maxlen`` of 0 (the default) will scan the entire list. + + For more information check https://redis.io/commands/lpos """ pieces = [name, value] if rank is not None: @@ -1541,6 +1879,7 @@ def sort(self, name, start=None, num=None, by=None, get=None, elements, sort will return a list of tuples, each containing the values fetched from the arguments to ``get``. + For more information check https://redis.io/commands/sort """ if (start is not None and num is None) or \ (num is not None and start is None): @@ -1591,6 +1930,8 @@ def scan(self, cursor=0, match=None, count=None, _type=None): Stock Redis instances allow for the following types: HASH, LIST, SET, STREAM, STRING, ZSET Additionally, Redis modules can expose other types as well. + + For more information check https://redis.io/commands/scan """ pieces = [cursor] if match is not None: @@ -1630,6 +1971,8 @@ def sscan(self, name, cursor=0, match=None, count=None): ``match`` allows for filtering the keys by pattern ``count`` allows for hint the minimum number of returns + + For more information check https://redis.io/commands/sscan """ pieces = [name, cursor] if match is not None: @@ -1661,6 +2004,8 @@ def hscan(self, name, cursor=0, match=None, count=None): ``match`` allows for filtering the keys by pattern ``count`` allows for hint the minimum number of returns + + For more information check https://redis.io/commands/hscan """ pieces = [name, cursor] if match is not None: @@ -1695,6 +2040,8 @@ def zscan(self, name, cursor=0, match=None, count=None, ``count`` allows for hint the minimum number of returns ``score_cast_func`` a callable used to cast the score return value + + For more information check https://redis.io/commands/zscan """ pieces = [name, cursor] if match is not None: @@ -1725,15 +2072,27 @@ def zscan_iter(self, name, match=None, count=None, # SET COMMANDS def sadd(self, name, *values): - """Add ``value(s)`` to set ``name``""" + """ + Add ``value(s)`` to set ``name`` + + For more information check https://redis.io/commands/sadd + """ return self.execute_command('SADD', name, *values) def scard(self, name): - """Return the number of elements in set ``name``""" + """ + Return the number of elements in set ``name`` + + For more information check https://redis.io/commands/scard + """ return self.execute_command('SCARD', name) def sdiff(self, keys, *args): - """Return the difference of sets specified by ``keys``""" + """ + Return the difference of sets specified by ``keys`` + + For more information check https://redis.io/commands/sdiff + """ args = list_or_args(keys, args) return self.execute_command('SDIFF', *args) @@ -1741,12 +2100,18 @@ def sdiffstore(self, dest, keys, *args): """ Store the difference of sets specified by ``keys`` into a new set named ``dest``. Returns the number of keys in the new set. + + For more information check https://redis.io/commands/sdiffstore """ args = list_or_args(keys, args) return self.execute_command('SDIFFSTORE', dest, *args) def sinter(self, keys, *args): - """Return the intersection of sets specified by ``keys``""" + """ + Return the intersection of sets specified by ``keys`` + + For more information check https://redis.io/commands/sinter + """ args = list_or_args(keys, args) return self.execute_command('SINTER', *args) @@ -1754,6 +2119,8 @@ def sinterstore(self, dest, keys, *args): """ Store the intersection of sets specified by ``keys`` into a new set named ``dest``. Returns the number of keys in the new set. + + For more information check https://redis.io/commands/sinterstore """ args = list_or_args(keys, args) return self.execute_command('SINTERSTORE', dest, *args) @@ -1761,27 +2128,43 @@ def sinterstore(self, dest, keys, *args): def sismember(self, name, value): """ Return a boolean indicating if ``value`` is a member of set ``name`` + + For more information check https://redis.io/commands/sismember """ return self.execute_command('SISMEMBER', name, value) def smembers(self, name): - """Return all members of the set ``name``""" + """ + Return all members of the set ``name`` + + For more information check https://redis.io/commands/smembers + """ return self.execute_command('SMEMBERS', name) def smismember(self, name, values, *args): """ Return whether each value in ``values`` is a member of the set ``name`` as a list of ``bool`` in the order of ``values`` + + For more information check https://redis.io/commands/smismember """ args = list_or_args(values, args) return self.execute_command('SMISMEMBER', name, *args) def smove(self, src, dst, value): - """Move ``value`` from set ``src`` to set ``dst`` atomically""" + """ + Move ``value`` from set ``src`` to set ``dst`` atomically + + For more information check https://redis.io/commands/smove + """ return self.execute_command('SMOVE', src, dst, value) def spop(self, name, count=None): - "Remove and return a random member of set ``name``" + """ + Remove and return a random member of set ``name`` + + For more information check https://redis.io/commands/spop + """ args = (count is not None) and [count] or [] return self.execute_command('SPOP', name, *args) @@ -1792,16 +2175,26 @@ def srandmember(self, name, number=None): If ``number`` is supplied, returns a list of ``number`` random members of set ``name``. Note this is only available when running Redis 2.6+. + + For more information check https://redis.io/commands/srandmember """ args = (number is not None) and [number] or [] return self.execute_command('SRANDMEMBER', name, *args) def srem(self, name, *values): - "Remove ``values`` from set ``name``" + """ + Remove ``values`` from set ``name`` + + For more information check https://redis.io/commands/srem + """ return self.execute_command('SREM', name, *values) def sunion(self, keys, *args): - "Return the union of sets specified by ``keys``" + """ + Return the union of sets specified by ``keys`` + + For more information check https://redis.io/commands/sunion + """ args = list_or_args(keys, args) return self.execute_command('SUNION', *args) @@ -1809,6 +2202,8 @@ def sunionstore(self, dest, keys, *args): """ Store the union of sets specified by ``keys`` into a new set named ``dest``. Returns the number of keys in the new set. + + For more information check https://redis.io/commands/sunionstore """ args = list_or_args(keys, args) return self.execute_command('SUNIONSTORE', dest, *args) @@ -1820,6 +2215,8 @@ def xack(self, name, groupname, *ids): name: name of the stream. groupname: name of the consumer group. *ids: message ids to acknowledge. + + For more information check https://redis.io/commands/xack """ return self.execute_command('XACK', name, groupname, *ids) @@ -1837,6 +2234,8 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True, minid: the minimum id in the stream to query. Can't be specified with maxlen. limit: specifies the maximum number of entries to retrieve + + For more information check https://redis.io/commands/xadd """ pieces = [] if maxlen is not None and minid is not None: @@ -1883,6 +2282,8 @@ def xautoclaim(self, name, groupname, consumername, min_idle_time, command attempts to claim. Set to 100 by default. justid: optional boolean, false by default. Return just an array of IDs of messages successfully claimed, without returning the actual message + + For more information check https://redis.io/commands/xautoclaim """ try: if int(min_idle_time) < 0: @@ -1930,6 +2331,8 @@ def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, PEL assigned to a different client. justid: optional boolean, false by default. Return just an array of IDs of messages successfully claimed, without returning the actual message + + For more information check https://redis.io/commands/xclaim """ if not isinstance(min_idle_time, int) or min_idle_time < 0: raise DataError("XCLAIM min_idle_time must be a non negative " @@ -1971,6 +2374,8 @@ def xdel(self, name, *ids): Deletes one or more messages from a stream. name: name of the stream. *ids: message ids to delete. + + For more information check https://redis.io/commands/xdel """ return self.execute_command('XDEL', name, *ids) @@ -1980,6 +2385,8 @@ def xgroup_create(self, name, groupname, id='$', mkstream=False): name: name of the stream. groupname: name of the consumer group. id: ID of the last item in the stream to consider already delivered. + + For more information check https://redis.io/commands/xgroup-create """ pieces = ['XGROUP CREATE', name, groupname, id] if mkstream: @@ -1994,6 +2401,8 @@ def xgroup_delconsumer(self, name, groupname, consumername): name: name of the stream. groupname: name of the consumer group. consumername: name of consumer to delete + + For more information check https://redis.io/commands/xgroup-delconsumer """ return self.execute_command('XGROUP DELCONSUMER', name, groupname, consumername) @@ -2003,6 +2412,8 @@ def xgroup_destroy(self, name, groupname): Destroy a consumer group. name: name of the stream. groupname: name of the consumer group. + + For more information check https://redis.io/commands/xgroup-destroy """ return self.execute_command('XGROUP DESTROY', name, groupname) @@ -2014,6 +2425,8 @@ def xgroup_createconsumer(self, name, groupname, consumername): name: name of the stream. groupname: name of the consumer group. consumername: name of consumer to create. + + See: https://redis.io/commands/xgroup-createconsumer """ return self.execute_command('XGROUP CREATECONSUMER', name, groupname, consumername) @@ -2024,6 +2437,8 @@ def xgroup_setid(self, name, groupname, id): name: name of the stream. groupname: name of the consumer group. id: ID of the last item in the stream to consider already delivered. + + For more information check https://redis.io/commands/xgroup-setid """ return self.execute_command('XGROUP SETID', name, groupname, id) @@ -2032,6 +2447,8 @@ def xinfo_consumers(self, name, groupname): Returns general information about the consumers in the group. name: name of the stream. groupname: name of the consumer group. + + For more information check https://redis.io/commands/xinfo-consumers """ return self.execute_command('XINFO CONSUMERS', name, groupname) @@ -2039,6 +2456,8 @@ def xinfo_groups(self, name): """ Returns general information about the consumer groups of the stream. name: name of the stream. + + For more information check https://redis.io/commands/xinfo-groups """ return self.execute_command('XINFO GROUPS', name) @@ -2047,6 +2466,8 @@ def xinfo_stream(self, name, full=False): Returns general information about the stream. name: name of the stream. full: optional boolean, false by default. Return full summary + + For more information check https://redis.io/commands/xinfo-stream """ pieces = [name] options = {} @@ -2058,6 +2479,8 @@ def xinfo_stream(self, name, full=False): def xlen(self, name): """ Returns the number of elements in a given stream. + + For more information check https://redis.io/commands/xlen """ return self.execute_command('XLEN', name) @@ -2066,6 +2489,8 @@ def xpending(self, name, groupname): Returns information about pending messages of a group. name: name of the stream. groupname: name of the consumer group. + + For more information check https://redis.io/commands/xpending """ return self.execute_command('XPENDING', name, groupname) @@ -2083,7 +2508,6 @@ def xpending_range(self, name, groupname, idle=None, max: maximum stream ID. count: number of messages to return consumername: name of a consumer to filter by (optional). - """ if {min, max, count} == {None}: if idle is not None or consumername is not None: @@ -2126,6 +2550,8 @@ def xrange(self, name, min='-', max='+', count=None): meaning the latest available. count: if set, only return this many items, beginning with the earliest available. + + For more information check https://redis.io/commands/xrange """ pieces = [min, max] if count is not None: @@ -2144,6 +2570,8 @@ def xread(self, streams, count=None, block=None): count: if set, only return this many items, beginning with the earliest available. block: number of milliseconds to wait, if nothing already present. + + For more information check https://redis.io/commands/xread """ pieces = [] if block is not None: @@ -2176,6 +2604,8 @@ def xreadgroup(self, groupname, consumername, streams, count=None, earliest available. block: number of milliseconds to wait, if nothing already present. noack: do not add messages to the PEL + + For more information check https://redis.io/commands/xreadgroup """ pieces = [b'GROUP', groupname, consumername] if count is not None: @@ -2208,6 +2638,8 @@ def xrevrange(self, name, max='+', min='-', count=None): meaning the earliest available. count: if set, only return this many items, beginning with the latest available. + + For more information check https://redis.io/commands/xrevrange """ pieces = [max, min] if count is not None: @@ -2229,6 +2661,8 @@ def xtrim(self, name, maxlen=None, approximate=True, minid=None, minid: the minimum id in the stream to query Can't be specified with maxlen. limit: specifies the maximum number of entries to retrieve + + For more information check https://redis.io/commands/xtrim """ pieces = [] if maxlen is not None and minid is not None: @@ -2284,6 +2718,7 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, set. ``NX``, ``LT``, and ``GT`` are mutually exclusive options. + See: https://redis.io/commands/ZADD """ if not mapping: @@ -2317,13 +2752,19 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, return self.execute_command('ZADD', name, *pieces, **options) def zcard(self, name): - "Return the number of elements in the sorted set ``name``" + """ + Return the number of elements in the sorted set ``name`` + + For more information check https://redis.io/commands/zcard + """ return self.execute_command('ZCARD', name) def zcount(self, name, min, max): """ Returns the number of elements in the sorted set at key ``name`` with a score between ``min`` and ``max``. + + For more information check https://redis.io/commands/zcount """ return self.execute_command('ZCOUNT', name, min, max) @@ -2331,6 +2772,8 @@ def zdiff(self, keys, withscores=False): """ Returns the difference between the first and all successive input sorted sets provided in ``keys``. + + For more information check https://redis.io/commands/zdiff """ pieces = [len(keys), *keys] if withscores: @@ -2341,12 +2784,18 @@ def zdiffstore(self, dest, keys): """ Computes the difference between the first and all successive input sorted sets provided in ``keys`` and stores the result in ``dest``. + + For more information check https://redis.io/commands/zdiffstore """ pieces = [len(keys), *keys] return self.execute_command("ZDIFFSTORE", dest, *pieces) def zincrby(self, name, amount, value): - "Increment the score of ``value`` in sorted set ``name`` by ``amount``" + """ + Increment the score of ``value`` in sorted set ``name`` by ``amount`` + + For more information check https://redis.io/commands/zincrby + """ return self.execute_command('ZINCRBY', name, amount, value) def zinter(self, keys, aggregate=None, withscores=False): @@ -2358,6 +2807,8 @@ def zinter(self, keys, aggregate=None, withscores=False): exists. When this option is set to either MIN or MAX, the resulting set will contain the minimum or maximum score of an element across the inputs where it exists. + + For more information check https://redis.io/commands/zinter """ return self._zaggregate('ZINTER', None, keys, aggregate, withscores=withscores) @@ -2371,6 +2822,8 @@ def zinterstore(self, dest, keys, aggregate=None): When this option is set to either MIN or MAX, the resulting set will contain the minimum or maximum score of an element across the inputs where it exists. + + For more information check https://redis.io/commands/zinterstore """ return self._zaggregate('ZINTERSTORE', dest, keys, aggregate) @@ -2378,6 +2831,8 @@ def zlexcount(self, name, min, max): """ Return the number of items in the sorted set ``name`` between the lexicographical range ``min`` and ``max``. + + For more information check https://redis.io/commands/zlexcount """ return self.execute_command('ZLEXCOUNT', name, min, max) @@ -2385,6 +2840,8 @@ def zpopmax(self, name, count=None): """ Remove and return up to ``count`` members with the highest scores from the sorted set ``name``. + + For more information check https://redis.io/commands/zpopmax """ args = (count is not None) and [count] or [] options = { @@ -2396,6 +2853,8 @@ def zpopmin(self, name, count=None): """ Remove and return up to ``count`` members with the lowest scores from the sorted set ``name``. + + For more information check https://redis.io/commands/zpopmin """ args = (count is not None) and [count] or [] options = { @@ -2416,6 +2875,8 @@ def zrandmember(self, key, count=None, withscores=False): ``withscores`` The optional WITHSCORES modifier changes the reply so it includes the respective scores of the randomly selected elements from the sorted set. + + For more information check https://redis.io/commands/zrandmember """ params = [] if count is not None: @@ -2435,6 +2896,8 @@ def bzpopmax(self, keys, timeout=0): to one of the sorted sets. If timeout is 0, then block indefinitely. + + For more information check https://redis.io/commands/bzpopmax """ if timeout is None: timeout = 0 @@ -2452,6 +2915,8 @@ def bzpopmin(self, keys, timeout=0): to one of the sorted sets. If timeout is 0, then block indefinitely. + + For more information check https://redis.io/commands/bzpopmin """ if timeout is None: timeout = 0 @@ -2519,6 +2984,8 @@ def zrange(self, name, start, end, desc=False, withscores=False, ``offset`` and ``num`` are specified, then return a slice of the range. Can't be provided when using ``bylex``. + + For more information check https://redis.io/commands/zrange """ # Need to support ``desc`` also when using old redis version # because it was supported in 3.5.3 (of redis-py) @@ -2542,6 +3009,8 @@ def zrevrange(self, name, start, end, withscores=False, The return type is a list of (value, score) pairs ``score_cast_func`` a callable used to cast the score return value + + For more information check https://redis.io/commands/zrevrange """ pieces = ['ZREVRANGE', name, start, end] if withscores: @@ -2575,6 +3044,8 @@ def zrangestore(self, dest, name, start, end, ``offset`` and ``num`` are specified, then return a slice of the range. Can't be provided when using ``bylex``. + + For more information check https://redis.io/commands/zrangestore """ return self._zrange('ZRANGESTORE', dest, name, start, end, desc, byscore, bylex, False, None, offset, num) @@ -2586,6 +3057,8 @@ def zrangebylex(self, name, min, max, start=None, num=None): If ``start`` and ``num`` are specified, then return a slice of the range. + + For more information check https://redis.io/commands/zrangebylex """ if (start is not None and num is None) or \ (num is not None and start is None): @@ -2602,6 +3075,8 @@ def zrevrangebylex(self, name, max, min, start=None, num=None): If ``start`` and ``num`` are specified, then return a slice of the range. + + For more information check https://redis.io/commands/zrevrangebylex """ if (start is not None and num is None) or \ (num is not None and start is None): @@ -2624,6 +3099,8 @@ def zrangebyscore(self, name, min, max, start=None, num=None, The return type is a list of (value, score) pairs `score_cast_func`` a callable used to cast the score return value + + For more information check https://redis.io/commands/zrangebyscore """ if (start is not None and num is None) or \ (num is not None and start is None): @@ -2652,6 +3129,8 @@ def zrevrangebyscore(self, name, max, min, start=None, num=None, The return type is a list of (value, score) pairs ``score_cast_func`` a callable used to cast the score return value + + For more information check https://redis.io/commands/zrevrangebyscore """ if (start is not None and num is None) or \ (num is not None and start is None): @@ -2671,11 +3150,17 @@ def zrank(self, name, value): """ Returns a 0-based value indicating the rank of ``value`` in sorted set ``name`` + + For more information check https://redis.io/commands/zrank """ return self.execute_command('ZRANK', name, value) def zrem(self, name, *values): - "Remove member ``values`` from sorted set ``name``" + """ + Remove member ``values`` from sorted set ``name`` + + For more information check https://redis.io/commands/zrem + """ return self.execute_command('ZREM', name, *values) def zremrangebylex(self, name, min, max): @@ -2684,6 +3169,8 @@ def zremrangebylex(self, name, min, max): lexicographical range specified by ``min`` and ``max``. Returns the number of elements removed. + + For more information check https://redis.io/commands/zremrangebylex """ return self.execute_command('ZREMRANGEBYLEX', name, min, max) @@ -2693,6 +3180,8 @@ def zremrangebyrank(self, name, min, max): ``min`` and ``max``. Values are 0-based, ordered from smallest score to largest. Values can be negative indicating the highest scores. Returns the number of elements removed + + For more information check https://redis.io/commands/zremrangebyrank """ return self.execute_command('ZREMRANGEBYRANK', name, min, max) @@ -2700,6 +3189,8 @@ def zremrangebyscore(self, name, min, max): """ Remove all elements in the sorted set ``name`` with scores between ``min`` and ``max``. Returns the number of elements removed. + + For more information check https://redis.io/commands/zremrangebyscore """ return self.execute_command('ZREMRANGEBYSCORE', name, min, max) @@ -2707,11 +3198,17 @@ def zrevrank(self, name, value): """ Returns a 0-based value indicating the descending rank of ``value`` in sorted set ``name`` + + For more information check https://redis.io/commands/zrevrank """ return self.execute_command('ZREVRANK', name, value) def zscore(self, name, value): - "Return the score of element ``value`` in sorted set ``name``" + """ + Return the score of element ``value`` in sorted set ``name`` + + For more information check https://redis.io/commands/zscore + """ return self.execute_command('ZSCORE', name, value) def zunion(self, keys, aggregate=None, withscores=False): @@ -2720,6 +3217,8 @@ def zunion(self, keys, aggregate=None, withscores=False): ``keys`` can be provided as dictionary of keys and their weights. Scores will be aggregated based on the ``aggregate``, or SUM if none is provided. + + For more information check https://redis.io/commands/zunion """ return self._zaggregate('ZUNION', None, keys, aggregate, withscores=withscores) @@ -2729,6 +3228,8 @@ def zunionstore(self, dest, keys, aggregate=None): Union multiple sorted sets specified by ``keys`` into a new sorted set, ``dest``. Scores in the destination will be aggregated based on the ``aggregate``, or SUM if none is provided. + + For more information check https://redis.io/commands/zunionstore """ return self._zaggregate('ZUNIONSTORE', dest, keys, aggregate) @@ -2740,6 +3241,8 @@ def zmscore(self, key, members): Return type is a list of score. If the member does not exist, a None will be returned in corresponding position. + + For more information check https://redis.io/commands/zmscore """ if not members: raise DataError('ZMSCORE members must be a non-empty list') @@ -2772,53 +3275,93 @@ def _zaggregate(self, command, dest, keys, aggregate=None, # HYPERLOGLOG COMMANDS def pfadd(self, name, *values): - "Adds the specified elements to the specified HyperLogLog." + """ + Adds the specified elements to the specified HyperLogLog. + + For more information check https://redis.io/commands/pfadd + """ return self.execute_command('PFADD', name, *values) def pfcount(self, *sources): """ Return the approximated cardinality of the set observed by the HyperLogLog at key(s). + + For more information check https://redis.io/commands/pfcount """ return self.execute_command('PFCOUNT', *sources) def pfmerge(self, dest, *sources): - "Merge N different HyperLogLogs into a single one." + """ + Merge N different HyperLogLogs into a single one. + + For more information check https://redis.io/commands/pfmerge + """ return self.execute_command('PFMERGE', dest, *sources) # HASH COMMANDS def hdel(self, name, *keys): - "Delete ``keys`` from hash ``name``" + """ + Delete ``keys`` from hash ``name`` + + For more information check https://redis.io/commands/hdel + """ return self.execute_command('HDEL', name, *keys) def hexists(self, name, key): - "Returns a boolean indicating if ``key`` exists within hash ``name``" + """ + Returns a boolean indicating if ``key`` exists within hash ``name`` + + For more information check https://redis.io/commands/hexists + """ return self.execute_command('HEXISTS', name, key) def hget(self, name, key): - "Return the value of ``key`` within the hash ``name``" + """ + Return the value of ``key`` within the hash ``name`` + + For more information check https://redis.io/commands/hget + """ return self.execute_command('HGET', name, key) def hgetall(self, name): - "Return a Python dict of the hash's name/value pairs" + """ + Return a Python dict of the hash's name/value pairs + + For more information check https://redis.io/commands/hgetall + """ return self.execute_command('HGETALL', name) def hincrby(self, name, key, amount=1): - "Increment the value of ``key`` in hash ``name`` by ``amount``" + """ + Increment the value of ``key`` in hash ``name`` by ``amount`` + + For more information check https://redis.io/commands/hincrby + """ return self.execute_command('HINCRBY', name, key, amount) def hincrbyfloat(self, name, key, amount=1.0): """ Increment the value of ``key`` in hash ``name`` by floating ``amount`` + + For more information check https://redis.io/commands/hincrbyfloat """ return self.execute_command('HINCRBYFLOAT', name, key, amount) def hkeys(self, name): - "Return the list of keys within hash ``name``" + """ + Return the list of keys within hash ``name`` + + For more information check https://redis.io/commands/hkeys + """ return self.execute_command('HKEYS', name) def hlen(self, name): - "Return the number of elements in hash ``name``" + """ + Return the number of elements in hash ``name`` + + For more information check https://redis.io/commands/hlen + """ return self.execute_command('HLEN', name) def hset(self, name, key=None, value=None, mapping=None): @@ -2827,6 +3370,8 @@ def hset(self, name, key=None, value=None, mapping=None): ``mapping`` accepts a dict of key/value pairs that will be added to hash ``name``. Returns the number of fields that were added. + + For more information check https://redis.io/commands/hset """ if key is None and not mapping: raise DataError("'hset' with no key value pairs") @@ -2843,6 +3388,8 @@ def hsetnx(self, name, key, value): """ Set ``key`` to ``value`` within hash ``name`` if ``key`` does not exist. Returns 1 if HSETNX created a field, otherwise 0. + + For more information check https://redis.io/commands/hsetnx """ return self.execute_command('HSETNX', name, key, value) @@ -2850,6 +3397,8 @@ def hmset(self, name, mapping): """ Set key to value within hash ``name`` for each corresponding key and value from the ``mapping`` dict. + + For more information check https://redis.io/commands/hmset """ warnings.warn( '%s.hmset() is deprecated. Use %s.hset() instead.' @@ -2865,18 +3414,28 @@ def hmset(self, name, mapping): return self.execute_command('HMSET', name, *items) def hmget(self, name, keys, *args): - "Returns a list of values ordered identically to ``keys``" + """ + Returns a list of values ordered identically to ``keys`` + + For more information check https://redis.io/commands/hmget + """ args = list_or_args(keys, args) return self.execute_command('HMGET', name, *args) def hvals(self, name): - "Return the list of values within hash ``name``" + """ + Return the list of values within hash ``name`` + + For more information check https://redis.io/commands/hvals + """ return self.execute_command('HVALS', name) def hstrlen(self, name, key): """ Return the number of bytes stored in the value of ``key`` within hash ``name`` + + For more information check https://redis.io/commands/hstrlen """ return self.execute_command('HSTRLEN', name, key) @@ -2884,18 +3443,24 @@ def publish(self, channel, message): """ Publish ``message`` on ``channel``. Returns the number of subscribers the message was delivered to. + + For more information check https://redis.io/commands/publish """ return self.execute_command('PUBLISH', channel, message) def pubsub_channels(self, pattern='*'): """ Return a list of channels that have at least one subscriber + + For more information check https://redis.io/commands/pubsub-channels """ return self.execute_command('PUBSUB CHANNELS', pattern) def pubsub_numpat(self): """ Returns the number of subscriptions to patterns + + For more information check https://redis.io/commands/pubsub-numpat """ return self.execute_command('PUBSUB NUMPAT') @@ -2903,6 +3468,8 @@ def pubsub_numsub(self, *args): """ Return a list of (channel, number of subscribers) tuples for each channel given in ``*args`` + + For more information check https://redis.io/commands/pubsub-numsub """ return self.execute_command('PUBSUB NUMSUB', *args) @@ -2915,7 +3482,8 @@ def replicaof(self, *args): Examples of valid arguments include: NO ONE (set no replication) host port (set to the host and port of a redis server) - see: https://redis.io/commands/replicaof + + For more information check https://redis.io/commands/replicaof """ return self.execute_command('REPLICAOF', *args) @@ -2927,6 +3495,8 @@ def eval(self, script, numkeys, *keys_and_args): In practice, use the object returned by ``register_script``. This function exists purely for Redis API completion. + + For more information check https://redis.io/commands/eval """ return self.execute_command('EVAL', script, numkeys, *keys_and_args) @@ -2939,6 +3509,8 @@ def evalsha(self, sha, numkeys, *keys_and_args): In practice, use the object returned by ``register_script``. This function exists purely for Redis API completion. + + For more information check https://redis.io/commands/evalsha """ return self.execute_command('EVALSHA', sha, numkeys, *keys_and_args) @@ -2947,6 +3519,8 @@ def script_exists(self, *args): Check if a script exists in the script cache by specifying the SHAs of each script as ``args``. Returns a list of boolean values indicating if if each already script exists in the cache. + + For more information check https://redis.io/commands/script-exists """ return self.execute_command('SCRIPT EXISTS', *args) @@ -2959,7 +3533,7 @@ def script_flush(self, sync_type=None): """Flush all scripts from the script cache. ``sync_type`` is by default SYNC (synchronous) but it can also be ASYNC. - See: https://redis.io/commands/script-flush + For more information check https://redis.io/commands/script-flush """ # Redis pre 6 had no sync_type. @@ -2974,11 +3548,19 @@ def script_flush(self, sync_type=None): return self.execute_command('SCRIPT FLUSH', *pieces) def script_kill(self): - "Kill the currently executing Lua script" + """ + Kill the currently executing Lua script + + For more information check https://redis.io/commands/script-kill + """ return self.execute_command('SCRIPT KILL') def script_load(self, script): - "Load a Lua ``script`` into the script cache. Returns the SHA." + """ + Load a Lua ``script`` into the script cache. Returns the SHA. + + For more information check https://redis.io/commands/script-load + """ return self.execute_command('SCRIPT LOAD', script) def register_script(self, script): @@ -3009,6 +3591,8 @@ def geoadd(self, name, values, nx=False, xx=False, ch=False): ``ch`` modifies the return value to be the numbers of elements changed. Changed elements include new elements that were added and elements whose scores changed. + + For more information check https://redis.io/commands/geoadd """ if nx and xx: raise DataError("GEOADD allows either 'nx' or 'xx', not both") @@ -3031,6 +3615,8 @@ def geodist(self, name, place1, place2, unit=None): ``name`` key. The units must be one of the following : m, km mi, ft. By default meters are used. + + For more information check https://redis.io/commands/geodist """ pieces = [name, place1, place2] if unit and unit not in ('m', 'km', 'mi', 'ft'): @@ -3043,6 +3629,8 @@ def geohash(self, name, *values): """ Return the geo hash string for each item of ``values`` members of the specified key identified by the ``name`` argument. + + For more information check https://redis.io/commands/geohash """ return self.execute_command('GEOHASH', name, *values) @@ -3051,6 +3639,8 @@ def geopos(self, name, *values): Return the positions of each item of ``values`` as members of the specified key identified by the ``name`` argument. Each position is represented by the pairs lon and lat. + + For more information check https://redis.io/commands/geopos """ return self.execute_command('GEOPOS', name, *values) @@ -3084,6 +3674,8 @@ def georadius(self, name, longitude, latitude, radius, unit=None, ``store_dist`` indicates to save the places names in a sorted set named with a specific key, instead of ``store`` the sorted set destination score is set with the distance. + + For more information check https://redis.io/commands/georadius """ return self._georadiusgeneric('GEORADIUS', name, longitude, latitude, radius, @@ -3101,6 +3693,8 @@ def georadiusbymember(self, name, member, radius, unit=None, that instead of taking, as the center of the area to query, a longitude and latitude value, it takes the name of a member already existing inside the geospatial index represented by the sorted set. + + For more information check https://redis.io/commands/georadiusbymember """ return self._georadiusgeneric('GEORADIUSBYMEMBER', name, member, radius, unit=unit, @@ -3188,6 +3782,8 @@ def geosearch(self, name, member=None, longitude=None, latitude=None, ``withcoord`` indicates to return the latitude and longitude of each place. ``withhash`` indicates to return the geohash string of each place. + + For more information check https://redis.io/commands/geosearch """ return self._geosearchgeneric('GEOSEARCH', @@ -3210,6 +3806,8 @@ def geosearchstore(self, dest, name, member=None, longitude=None, if ``store_dist`` set to True, the command will stores the items in a sorted set populated with their distance from the center of the circle or box, as a floating-point number. + + For more information check https://redis.io/commands/geosearchstore """ return self._geosearchgeneric('GEOSEARCHSTORE', dest, name, member=member, @@ -3290,6 +3888,8 @@ def module_load(self, path, *args): Loads the module from ``path``. Passes all ``*args`` to the module, during loading. Raises ``ModuleError`` if a module is not found at ``path``. + + For more information check https://redis.io/commands/module-load """ return self.execute_command('MODULE LOAD', path, *args) @@ -3297,6 +3897,8 @@ def module_unload(self, name): """ Unloads the module ``name``. Raises ``ModuleError`` if ``name`` is not in loaded modules. + + For more information check https://redis.io/commands/module-unload """ return self.execute_command('MODULE UNLOAD', name) @@ -3304,6 +3906,8 @@ def module_list(self): """ Returns a list of dictionaries containing the name and version of all loaded modules. + + For more information check https://redis.io/commands/module-list """ return self.execute_command('MODULE LIST') From 021d4ac0edaecedb9b83235700cc4699cb119ef1 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 14:14:56 +0200 Subject: [PATCH 0262/1164] 4.1.0rc1 (#1742) --- redis/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/__init__.py b/redis/__init__.py index dc9b11a7b8..e7c6d05c5a 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -37,7 +37,7 @@ def int_or_str(value): return value -__version__ = "4.0.2" +__version__ = "4.1.0rc1" VERSION = tuple(map(int_or_str, __version__.split('.'))) From 9db1eec71b443b8e7e74ff503bae651dc6edf411 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Thu, 25 Nov 2021 14:15:24 +0200 Subject: [PATCH 0263/1164] Adding RedisCluster client to support Redis Cluster Mode (#1660) Co-authored-by: Chayim Co-authored-by: Anas --- .github/workflows/install_and_test.sh | 6 +- CONTRIBUTING.md | 8 + README.md | 268 ++- docker/base/Dockerfile.cluster | 8 + docker/base/create_cluster.sh | 26 + docker/cluster/redis.conf | 3 + redis/__init__.py | 2 + redis/client.py | 39 +- redis/cluster.py | 2066 ++++++++++++++++++++ redis/commands/__init__.py | 10 +- redis/commands/cluster.py | 926 +++++++++ redis/commands/core.py | 153 +- redis/commands/parser.py | 118 ++ redis/connection.py | 29 +- redis/crc.py | 24 + redis/exceptions.py | 102 + redis/utils.py | 36 + tasks.py | 19 +- tests/conftest.py | 95 +- tests/test_cluster.py | 2482 +++++++++++++++++++++++++ tests/test_command_parser.py | 62 + tests/test_commands.py | 138 +- tests/test_connection.py | 1 + tests/test_connection_pool.py | 5 + tests/test_json.py | 1 + tests/test_lock.py | 2 + tests/test_monitor.py | 2 + tests/test_pipeline.py | 14 + tests/test_pubsub.py | 13 +- tests/test_scripting.py | 1 + tests/test_sentinel.py | 15 + tox.ini | 26 +- 32 files changed, 6628 insertions(+), 72 deletions(-) create mode 100644 docker/base/Dockerfile.cluster create mode 100644 docker/base/create_cluster.sh create mode 100644 docker/cluster/redis.conf create mode 100644 redis/cluster.py create mode 100644 redis/commands/cluster.py create mode 100644 redis/commands/parser.py create mode 100644 redis/crc.py create mode 100644 tests/test_cluster.py create mode 100644 tests/test_command_parser.py diff --git a/.github/workflows/install_and_test.sh b/.github/workflows/install_and_test.sh index 330102eb41..7a8cd672fd 100755 --- a/.github/workflows/install_and_test.sh +++ b/.github/workflows/install_and_test.sh @@ -38,4 +38,8 @@ cd ${TESTDIR} # install, run tests pip install ${PKG} -pytest +# Redis tests +pytest -m 'not onlycluster' +# RedisCluster tests +CLUSTER_URL="redis://localhost:16379/0" +pytest -m 'not onlynoncluster and not redismod' --redis-url=${CLUSTER_URL} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af067e7fdf..fe37ff9abe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,14 @@ configuration](https://redis.io/topics/sentinel). ## Testing +Call `invoke tests` to run all tests, or `invoke all-tests` to run linters +tests as well. With the 'tests' and 'all-tests' targets, all Redis and +RedisCluster tests will be run. + +It is possible to run only Redis client tests (with cluster mode disabled) by +using `invoke redis-tests`; similarly, RedisCluster tests can be run by using +`invoke cluster-tests`. + Each run of tox starts and stops the various dockers required. Sometimes things get stuck, an `invoke clean` can help. diff --git a/README.md b/README.md index f03053e691..6e7d964b2b 100644 --- a/README.md +++ b/README.md @@ -948,8 +948,272 @@ C 3 ### Cluster Mode -redis-py does not currently support [Cluster -Mode](https://redis.io/topics/cluster-tutorial). +redis-py is now supports cluster mode and provides a client for +[Redis Cluster](). + +The cluster client is based on Grokzen's +[redis-py-cluster](https://github.com/Grokzen/redis-py-cluster), has added bug +fixes, and now supersedes that library. Support for these changes is thanks to +his contributions. + + +**Create RedisCluster:** + +Connecting redis-py to a Redis Cluster instance(s) requires at a minimum a +single node for cluster discovery. There are multiple ways in which a cluster +instance can be created: + +- Using 'host' and 'port' arguments: + +``` pycon + >>> from redis.cluster import RedisCluster as Redis + >>> rc = Redis(host='localhost', port=6379) + >>> print(rc.get_nodes()) + [[host=127.0.0.1,port=6379,name=127.0.0.1:6379,server_type=primary,redis_connection=Redis>>], [host=127.0.0.1,port=6378,name=127.0.0.1:6378,server_type=primary,redis_connection=Redis>>], [host=127.0.0.1,port=6377,name=127.0.0.1:6377,server_type=replica,redis_connection=Redis>>]] +``` +- Using the Redis URL specification: + +``` pycon + >>> from redis.cluster import RedisCluster as Redis + >>> rc = Redis.from_url("redis://localhost:6379/0") +``` + +- Directly, via the ClusterNode class: + +``` pycon + >>> from redis.cluster import RedisCluster as Redis + >>> from redis.cluster import ClusterNode + >>> nodes = [ClusterNode('localhost', 6379), ClusterNode('localhost', 6378)] + >>> rc = Redis(startup_nodes=nodes) +``` + +When a RedisCluster instance is being created it first attempts to establish a +connection to one of the provided startup nodes. If none of the startup nodes +are reachable, a 'RedisClusterException' will be thrown. +After a connection to the one of the cluster's nodes is established, the +RedisCluster instance will be initialized with 3 caches: +a slots cache which maps each of the 16384 slots to the node/s handling them, +a nodes cache that contains ClusterNode objects (name, host, port, redis connection) +for all of the cluster's nodes, and a commands cache contains all the server +supported commands that were retrieved using the Redis 'COMMAND' output. + +RedisCluster instance can be directly used to execute Redis commands. When a +command is being executed through the cluster instance, the target node(s) will +be internally determined. When using a key-based command, the target node will +be the node that holds the key's slot. +Cluster management commands and other commands that are not key-based have a +parameter called 'target_nodes' where you can specify which nodes to execute +the command on. In the absence of target_nodes, the command will be executed +on the default cluster node. As part of cluster instance initialization, the +cluster's default node is randomly selected from the cluster's primaries, and +will be updated upon reinitialization. Using r.get_default_node(), you can +get the cluster's default node, or you can change it using the +'set_default_node' method. + +The 'target_nodes' parameter is explained in the following section, +'Specifying Target Nodes'. + +``` pycon + >>> # target-nodes: the node that holds 'foo1's key slot + >>> rc.set('foo1', 'bar1') + >>> # target-nodes: the node that holds 'foo2's key slot + >>> rc.set('foo2', 'bar2') + >>> # target-nodes: the node that holds 'foo1's key slot + >>> print(rc.get('foo1')) + b'bar' + >>> # target-node: default-node + >>> print(rc.keys()) + [b'foo1'] + >>> # target-node: default-node + >>> rc.ping() +``` + +**Specifying Target Nodes:** + +As mentioned above, all non key-based RedisCluster commands accept the kwarg +parameter 'target_nodes' that specifies the node/nodes that the command should +be executed on. +The best practice is to specify target nodes using RedisCluster class's node +flags: PRIMARIES, REPLICAS, ALL_NODES, RANDOM. When a nodes flag is passed +along with a command, it will be internally resolved to the relevant node/s. +If the nodes topology of the cluster changes during the execution of a command, +the client will be able to resolve the nodes flag again with the new topology +and attempt to retry executing the command. + +``` pycon + >>> from redis.cluster import RedisCluster as Redis + >>> # run cluster-meet command on all of the cluster's nodes + >>> rc.cluster_meet('127.0.0.1', 6379, target_nodes=Redis.ALL_NODES) + >>> # ping all replicas + >>> rc.ping(target_nodes=Redis.REPLICAS) + >>> # ping a specific node + >>> rc.ping(target_nodes=Redis.RANDOM) + >>> # get the keys from all cluster nodes + >>> rc.keys(target_nodes=Redis.ALL_NODES) + [b'foo1', b'foo2'] + >>> # execute bgsave in all primaries + >>> rc.bgsave(Redis.PRIMARIES) +``` + +You could also pass ClusterNodes directly if you want to execute a command on a +specific node / node group that isn't addressed by the nodes flag. However, if +the command execution fails due to cluster topology changes, a retry attempt +will not be made, since the passed target node/s may no longer be valid, and +the relevant cluster or connection error will be returned. + +``` pycon + >>> node = rc.get_node('localhost', 6379) + >>> # Get the keys only for that specific node + >>> rc.keys(target_nodes=node) + >>> # get Redis info from a subset of primaries + >>> subset_primaries = [node for node in rc.get_primaries() if node.port > 6378] + >>> rc.info(target_nodes=subset_primaries) +``` + +In addition, the RedisCluster instance can query the Redis instance of a +specific node and execute commands on that node directly. The Redis client, +however, does not handle cluster failures and retries. + +``` pycon + >>> cluster_node = rc.get_node(host='localhost', port=6379) + >>> print(cluster_node) + [host=127.0.0.1,port=6379,name=127.0.0.1:6379,server_type=primary,redis_connection=Redis>>] + >>> r = cluster_node.redis_connection + >>> r.client_list() + [{'id': '276', 'addr': '127.0.0.1:64108', 'fd': '16', 'name': '', 'age': '0', 'idle': '0', 'flags': 'N', 'db': '0', 'sub': '0', 'psub': '0', 'multi': '-1', 'qbuf': '26', 'qbuf-free': '32742', 'argv-mem': '10', 'obl': '0', 'oll': '0', 'omem': '0', 'tot-mem': '54298', 'events': 'r', 'cmd': 'client', 'user': 'default'}] + >>> # Get the keys only for that specific node + >>> r.keys() + [b'foo1'] +``` + +**Multi-key commands:** + +Redis supports multi-key commands in Cluster Mode, such as Set type unions or +intersections, mset and mget, as long as the keys all hash to the same slot. +By using RedisCluster client, you can use the known functions (e.g. mget, mset) +to perform an atomic multi-key operation. However, you must ensure all keys are +mapped to the same slot, otherwise a RedisClusterException will be thrown. +Redis Cluster implements a concept called hash tags that can be used in order +to force certain keys to be stored in the same hash slot, see +[Keys hash tag](https://redis.io/topics/cluster-spec#keys-hash-tags). +You can also use nonatomic for some of the multikey operations, and pass keys +that aren't mapped to the same slot. The client will then map the keys to the +relevant slots, sending the commands to the slots' node owners. Non-atomic +operations batch the keys according to their hash value, and then each batch is +sent separately to the slot's owner. + +``` pycon + # Atomic operations can be used when all keys are mapped to the same slot + >>> rc.mset({'{foo}1': 'bar1', '{foo}2': 'bar2'}) + >>> rc.mget('{foo}1', '{foo}2') + [b'bar1', b'bar2'] + # Non-atomic multi-key operations splits the keys into different slots + >>> rc.mset_nonatomic({'foo': 'value1', 'bar': 'value2', 'zzz': 'value3') + >>> rc.mget_nonatomic('foo', 'bar', 'zzz') + [b'value1', b'value2', b'value3'] +``` + +**Cluster PubSub:** + +When a ClusterPubSub instance is created without specifying a node, a single +node will be transparently chosen for the pubsub connection on the +first command execution. The node will be determined by: + 1. Hashing the channel name in the request to find its keyslot + 2. Selecting a node that handles the keyslot: If read_from_replicas is + set to true, a replica can be selected. + +*Known limitations with pubsub:* + +Pattern subscribe and publish do not currently work properly due to key slots. +If we hash a pattern like fo* we will receive a keyslot for that string but +there are endless possibilities for channel names based on this pattern - +unknowable in advance. This feature is not disabled but the commands are not +currently recommended for use. +See [redis-py-cluster documentation](https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html) + for more. + +``` pycon + >>> p1 = rc.pubsub() + # p1 connection will be set to the node that holds 'foo' keyslot + >>> p1.subscribe('foo') + # p2 connection will be set to node 'localhost:6379' + >>> p2 = rc.pubsub(rc.get_node('localhost', 6379)) +``` + +**Read Only Mode** + +By default, Redis Cluster always returns MOVE redirection response on accessing +a replica node. You can overcome this limitation and scale read commands by +triggering READONLY mode. + +To enable READONLY mode pass read_from_replicas=True to RedisCluster +constructor. When set to true, read commands will be assigned between the +primary and its replications in a Round-Robin manner. + +READONLY mode can be set at runtime by calling the readonly() method with +target_nodes='replicas', and read-write access can be restored by calling the +readwrite() method. + +``` pycon + >>> from cluster import RedisCluster as Redis + # Use 'debug' log level to print the node that the command is executed on + >>> rc_readonly = Redis(startup_nodes=startup_nodes, + read_from_replicas=True, debug=True) + >>> rc_readonly.set('{foo}1', 'bar1') + >>> for i in range(0, 4): + # Assigns read command to the slot's hosts in a Round-Robin manner + >>> rc_readonly.get('{foo}1') + # set command would be directed only to the slot's primary node + >>> rc_readonly.set('{foo}2', 'bar2') + # reset READONLY flag + >>> rc_readonly.readwrite(target_nodes='replicas') + # now the get command would be directed only to the slot's primary node + >>> rc_readonly.get('{foo}1') +``` + +**Cluster Pipeline** + +ClusterPipeline is a subclass of RedisCluster that provides support for Redis +pipelines in cluster mode. +When calling the execute() command, all the commands are grouped by the node +on which they will be executed, and are then executed by the respective nodes +in parallel. The pipeline instance will wait for all the nodes to respond +before returning the result to the caller. Command responses are returned as a +list sorted in the same order in which they were sent. +Pipelines can be used to dramatically increase the throughput of Redis Cluster +by significantly reducing the the number of network round trips between the +client and the server. + +``` pycon + >>> with rc.pipeline() as pipe: + >>> pipe.set('foo', 'value1') + >>> pipe.set('bar', 'value2') + >>> pipe.get('foo') + >>> pipe.get('bar') + >>> print(pipe.execute()) + [True, True, b'value1', b'value2'] + >>> pipe.set('foo1', 'bar1').get('foo1').execute() + [True, b'bar1'] +``` +Please note: +- RedisCluster pipelines currently only support key-based commands. +- The pipeline gets its 'read_from_replicas' value from the cluster's parameter. +Thus, if read from replications is enabled in the cluster instance, the pipeline +will also direct read commands to replicas. +- The 'transcation' option is NOT supported in cluster-mode. In non-cluster mode, +the 'transaction' option is available when executing pipelines. This wraps the +pipeline commands with MULTI/EXEC commands, and effectively turns the pipeline +commands into a single transaction block. This means that all commands are +executed sequentially without any interruptions from other clients. However, +in cluster-mode this is not possible, because commands are partitioned +according to their respective destination nodes. This means that we can not +turn the pipeline commands into one transaction block, because in most cases +they are split up into several smaller pipelines. + + +See [Redis Cluster tutorial](https://redis.io/topics/cluster-tutorial) and +[Redis Cluster specifications](https://redis.io/topics/cluster-spec) +to learn more about Redis Cluster. ### Author diff --git a/docker/base/Dockerfile.cluster b/docker/base/Dockerfile.cluster new file mode 100644 index 0000000000..70e8013631 --- /dev/null +++ b/docker/base/Dockerfile.cluster @@ -0,0 +1,8 @@ +FROM redis:6.2.6-buster + +COPY create_cluster.sh /create_cluster.sh +RUN chmod +x /create_cluster.sh + +EXPOSE 16379 16380 16381 16382 16383 16384 + +CMD [ "/create_cluster.sh"] \ No newline at end of file diff --git a/docker/base/create_cluster.sh b/docker/base/create_cluster.sh new file mode 100644 index 0000000000..82a79c80da --- /dev/null +++ b/docker/base/create_cluster.sh @@ -0,0 +1,26 @@ +#! /bin/bash +mkdir -p /nodes +touch /nodes/nodemap +for PORT in $(seq 16379 16384); do + mkdir -p /nodes/$PORT + if [[ -e /redis.conf ]]; then + cp /redis.conf /nodes/$PORT/redis.conf + else + touch /nodes/$PORT/redis.conf + fi + cat << EOF >> /nodes/$PORT/redis.conf +port ${PORT} +cluster-enabled yes +daemonize yes +logfile /redis.log +dir /nodes/$PORT +EOF + redis-server /nodes/$PORT/redis.conf + if [ $? -ne 0 ]; then + echo "Redis failed to start, exiting." + exit 3 + fi + echo 127.0.0.1:$PORT >> /nodes/nodemap +done +echo yes | redis-cli --cluster create $(seq -f 127.0.0.1:%g 16379 16384) --cluster-replicas 1 +tail -f /redis.log diff --git a/docker/cluster/redis.conf b/docker/cluster/redis.conf new file mode 100644 index 0000000000..dff658c79b --- /dev/null +++ b/docker/cluster/redis.conf @@ -0,0 +1,3 @@ +# Redis Cluster config file will be shared across all nodes. +# Do not change the following configurations that are already set: +# port, cluster-enabled, daemonize, logfile, dir diff --git a/redis/__init__.py b/redis/__init__.py index e7c6d05c5a..bc7f3c9d9c 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -1,4 +1,5 @@ from redis.client import Redis, StrictRedis +from redis.cluster import RedisCluster from redis.connection import ( BlockingConnectionPool, ConnectionPool, @@ -57,6 +58,7 @@ def int_or_str(value): 'PubSubError', 'ReadOnlyError', 'Redis', + 'RedisCluster', 'RedisError', 'ResponseError', 'Sentinel', diff --git a/redis/client.py b/redis/client.py index dc6693d111..143ed88731 100755 --- a/redis/client.py +++ b/redis/client.py @@ -461,6 +461,7 @@ def _parse_node_line(line): line_items = line.split(' ') node_id, addr, flags, master_id, ping, pong, epoch, \ connected = line.split(' ')[:8] + addr = addr.split('@')[0] slots = [sl.split('-') for sl in line_items[8:]] node_dict = { 'node_id': node_id, @@ -476,8 +477,13 @@ def _parse_node_line(line): def parse_cluster_nodes(response, **options): - raw_lines = str_if_bytes(response).splitlines() - return dict(_parse_node_line(line) for line in raw_lines) + """ + @see: https://redis.io/commands/cluster-nodes # string + @see: https://redis.io/commands/cluster-replicas # list of string + """ + if isinstance(response, str): + response = response.splitlines() + return dict(_parse_node_line(str_if_bytes(node)) for node in response) def parse_geosearch_generic(response, **options): @@ -516,6 +522,21 @@ def parse_geosearch_generic(response, **options): ] +def parse_command(response, **options): + commands = {} + for command in response: + cmd_dict = {} + cmd_name = str_if_bytes(command[0]) + cmd_dict['name'] = cmd_name + cmd_dict['arity'] = int(command[1]) + cmd_dict['flags'] = [str_if_bytes(flag) for flag in command[2]] + cmd_dict['first_key_pos'] = command[3] + cmd_dict['last_key_pos'] = command[4] + cmd_dict['step_count'] = command[5] + commands[cmd_name] = cmd_dict + return commands + + def parse_pubsub_numsub(response, **options): return list(zip(response[0::2], response[1::2])) @@ -704,7 +725,10 @@ class Redis(RedisModuleCommands, CoreCommands, SentinelCommands, object): 'CLUSTER SET-CONFIG-EPOCH': bool_ok, 'CLUSTER SETSLOT': bool_ok, 'CLUSTER SLAVES': parse_cluster_nodes, + 'CLUSTER REPLICAS': parse_cluster_nodes, + 'COMMAND': parse_command, 'COMMAND COUNT': int, + 'COMMAND GETKEYS': lambda r: list(map(str_if_bytes, r)), 'CONFIG GET': parse_config_get, 'CONFIG RESETSTAT': bool_ok, 'CONFIG SET': bool_ok, @@ -827,7 +851,7 @@ def __init__(self, host='localhost', port=6379, ssl_check_hostname=False, max_connections=None, single_connection_client=False, health_check_interval=0, client_name=None, username=None, - retry=None): + retry=None, redis_connect_func=None): """ Initialize a new Redis client. To specify a retry policy, first set `retry_on_timeout` to `True` @@ -855,7 +879,8 @@ def __init__(self, host='localhost', port=6379, 'retry': copy.deepcopy(retry), 'max_connections': max_connections, 'health_check_interval': health_check_interval, - 'client_name': client_name + 'client_name': client_name, + 'redis_connect_func': redis_connect_func } # based on input, setup appropriate connection args if unix_socket_path is not None: @@ -1173,14 +1198,16 @@ class PubSub: HEALTH_CHECK_MESSAGE = 'redis-py-health-check' def __init__(self, connection_pool, shard_hint=None, - ignore_subscribe_messages=False): + ignore_subscribe_messages=False, encoder=None): self.connection_pool = connection_pool self.shard_hint = shard_hint self.ignore_subscribe_messages = ignore_subscribe_messages self.connection = None # we need to know the encoding options for this connection in order # to lookup channel and pattern names for callback handlers. - self.encoder = self.connection_pool.get_encoder() + self.encoder = encoder + if self.encoder is None: + self.encoder = self.connection_pool.get_encoder() if self.encoder.decode_responses: self.health_check_response = ['pong', self.HEALTH_CHECK_MESSAGE] else: diff --git a/redis/cluster.py b/redis/cluster.py new file mode 100644 index 0000000000..91a4d558a2 --- /dev/null +++ b/redis/cluster.py @@ -0,0 +1,2066 @@ +import copy +import logging +import random +import socket +import time +import threading +import sys + +from collections import OrderedDict +from redis.client import CaseInsensitiveDict, Redis, PubSub +from redis.commands import ( + ClusterCommands, + CommandsParser +) +from redis.connection import DefaultParser, ConnectionPool, Encoder, parse_url +from redis.crc import key_slot, REDIS_CLUSTER_HASH_SLOTS +from redis.exceptions import ( + AskError, + BusyLoadingError, + ClusterCrossSlotError, + ClusterDownError, + ClusterError, + DataError, + MasterDownError, + MovedError, + RedisClusterException, + RedisError, + ResponseError, + SlotNotCoveredError, + TimeoutError, + TryAgainError, +) +from redis.utils import ( + dict_merge, + list_keys_to_dict, + merge_result, + str_if_bytes, + safe_str +) + +log = logging.getLogger(__name__) + + +def get_node_name(host, port): + return '{0}:{1}'.format(host, port) + + +def get_connection(redis_node, *args, **options): + return redis_node.connection or redis_node.connection_pool.get_connection( + args[0], **options + ) + + +def parse_scan_result(command, res, **options): + keys_list = [] + for primary_res in res.values(): + keys_list += primary_res[1] + return 0, keys_list + + +def parse_pubsub_numsub(command, res, **options): + numsub_d = OrderedDict() + for numsub_tups in res.values(): + for channel, numsubbed in numsub_tups: + try: + numsub_d[channel] += numsubbed + except KeyError: + numsub_d[channel] = numsubbed + + ret_numsub = [ + (channel, numsub) + for channel, numsub in numsub_d.items() + ] + return ret_numsub + + +def parse_cluster_slots(resp, **options): + current_host = options.get('current_host', '') + + def fix_server(*args): + return str_if_bytes(args[0]) or current_host, args[1] + + slots = {} + for slot in resp: + start, end, primary = slot[:3] + replicas = slot[3:] + slots[start, end] = { + 'primary': fix_server(*primary), + 'replicas': [fix_server(*replica) for replica in replicas], + } + + return slots + + +PRIMARY = "primary" +REPLICA = "replica" +SLOT_ID = "slot-id" + +REDIS_ALLOWED_KEYS = ( + "charset", + "connection_class", + "connection_pool", + "db", + "decode_responses", + "encoding", + "encoding_errors", + "errors", + "host", + "max_connections", + "nodes_flag", + "redis_connect_func", + "password", + "port", + "retry", + "retry_on_timeout", + "socket_connect_timeout", + "socket_keepalive", + "socket_keepalive_options", + "socket_timeout", + "ssl", + "ssl_ca_certs", + "ssl_certfile", + "ssl_cert_reqs", + "ssl_keyfile", + "unix_socket_path", + "username", +) +KWARGS_DISABLED_KEYS = ( + "host", + "port", +) + +# Not complete, but covers the major ones +# https://redis.io/commands +READ_COMMANDS = frozenset([ + "BITCOUNT", + "BITPOS", + "EXISTS", + "GEODIST", + "GEOHASH", + "GEOPOS", + "GEORADIUS", + "GEORADIUSBYMEMBER", + "GET", + "GETBIT", + "GETRANGE", + "HEXISTS", + "HGET", + "HGETALL", + "HKEYS", + "HLEN", + "HMGET", + "HSTRLEN", + "HVALS", + "KEYS", + "LINDEX", + "LLEN", + "LRANGE", + "MGET", + "PTTL", + "RANDOMKEY", + "SCARD", + "SDIFF", + "SINTER", + "SISMEMBER", + "SMEMBERS", + "SRANDMEMBER", + "STRLEN", + "SUNION", + "TTL", + "ZCARD", + "ZCOUNT", + "ZRANGE", + "ZSCORE", +]) + + +def cleanup_kwargs(**kwargs): + """ + Remove unsupported or disabled keys from kwargs + """ + connection_kwargs = { + k: v + for k, v in kwargs.items() + if k in REDIS_ALLOWED_KEYS and k not in KWARGS_DISABLED_KEYS + } + + return connection_kwargs + + +class ClusterParser(DefaultParser): + EXCEPTION_CLASSES = dict_merge( + DefaultParser.EXCEPTION_CLASSES, { + 'ASK': AskError, + 'TRYAGAIN': TryAgainError, + 'MOVED': MovedError, + 'CLUSTERDOWN': ClusterDownError, + 'CROSSSLOT': ClusterCrossSlotError, + 'MASTERDOWN': MasterDownError, + }) + + +class RedisCluster(ClusterCommands, object): + RedisClusterRequestTTL = 16 + + PRIMARIES = "primaries" + REPLICAS = "replicas" + ALL_NODES = "all" + RANDOM = "random" + DEFAULT_NODE = "default-node" + + NODE_FLAGS = { + PRIMARIES, + REPLICAS, + ALL_NODES, + RANDOM, + DEFAULT_NODE + } + + COMMAND_FLAGS = dict_merge( + list_keys_to_dict( + [ + "CLIENT LIST", + "CLIENT SETNAME", + "CLIENT GETNAME", + "CONFIG SET", + "CONFIG REWRITE", + "CONFIG RESETSTAT", + "TIME", + "PUBSUB CHANNELS", + "PUBSUB NUMPAT", + "PUBSUB NUMSUB", + "PING", + "INFO", + "SHUTDOWN", + "KEYS", + "SCAN", + "FLUSHALL", + "FLUSHDB", + "DBSIZE", + "BGSAVE", + "SLOWLOG GET", + "SLOWLOG LEN", + "SLOWLOG RESET", + "WAIT", + "SAVE", + "MEMORY PURGE", + "MEMORY MALLOC-STATS", + "MEMORY STATS", + "LASTSAVE", + "CLIENT TRACKINGINFO", + "CLIENT PAUSE", + "CLIENT UNPAUSE", + "CLIENT UNBLOCK", + "CLIENT ID", + "CLIENT REPLY", + "CLIENT GETREDIR", + "CLIENT INFO", + "CLIENT KILL", + "READONLY", + "READWRITE", + "CLUSTER INFO", + "CLUSTER MEET", + "CLUSTER NODES", + "CLUSTER REPLICAS", + "CLUSTER RESET", + "CLUSTER SET-CONFIG-EPOCH", + "CLUSTER SLOTS", + "CLUSTER COUNT-FAILURE-REPORTS", + "CLUSTER KEYSLOT", + "COMMAND", + "COMMAND COUNT", + "COMMAND GETKEYS", + "CONFIG GET", + "DEBUG", + "RANDOMKEY", + "READONLY", + "READWRITE", + "TIME", + ], + DEFAULT_NODE, + ), + list_keys_to_dict( + [ + "CLUSTER COUNTKEYSINSLOT", + "CLUSTER DELSLOTS", + "CLUSTER GETKEYSINSLOT", + "CLUSTER SETSLOT", + ], + SLOT_ID, + ), + ) + + CLUSTER_COMMANDS_RESPONSE_CALLBACKS = { + 'CLUSTER ADDSLOTS': bool, + 'CLUSTER COUNT-FAILURE-REPORTS': int, + 'CLUSTER COUNTKEYSINSLOT': int, + 'CLUSTER DELSLOTS': bool, + 'CLUSTER FAILOVER': bool, + 'CLUSTER FORGET': bool, + 'CLUSTER GETKEYSINSLOT': list, + 'CLUSTER KEYSLOT': int, + 'CLUSTER MEET': bool, + 'CLUSTER REPLICATE': bool, + 'CLUSTER RESET': bool, + 'CLUSTER SAVECONFIG': bool, + 'CLUSTER SET-CONFIG-EPOCH': bool, + 'CLUSTER SETSLOT': bool, + 'CLUSTER SLOTS': parse_cluster_slots, + 'ASKING': bool, + 'READONLY': bool, + 'READWRITE': bool, + } + + RESULT_CALLBACKS = dict_merge( + list_keys_to_dict([ + "PUBSUB NUMSUB", + ], parse_pubsub_numsub), + list_keys_to_dict([ + "PUBSUB NUMPAT", + ], lambda command, res: sum(list(res.values()))), + list_keys_to_dict([ + "KEYS", + "PUBSUB CHANNELS", + ], merge_result), + list_keys_to_dict([ + "PING", + "CONFIG SET", + "CONFIG REWRITE", + "CONFIG RESETSTAT", + "CLIENT SETNAME", + "BGSAVE", + "SLOWLOG RESET", + "SAVE", + "MEMORY PURGE", + "CLIENT PAUSE", + "CLIENT UNPAUSE", + ], lambda command, res: all(res.values()) if isinstance(res, dict) + else res), + list_keys_to_dict([ + "DBSIZE", + "WAIT", + ], lambda command, res: sum(res.values()) if isinstance(res, dict) + else res), + list_keys_to_dict([ + "CLIENT UNBLOCK", + ], lambda command, res: 1 if sum(res.values()) > 0 else 0), + list_keys_to_dict([ + "SCAN", + ], parse_scan_result) + ) + + def __init__( + self, + host=None, + port=6379, + startup_nodes=None, + cluster_error_retry_attempts=3, + require_full_coverage=True, + skip_full_coverage_check=False, + reinitialize_steps=10, + read_from_replicas=False, + url=None, + retry_on_timeout=False, + retry=None, + **kwargs + ): + """ + Initialize a new RedisCluster client. + + :startup_nodes: 'list[ClusterNode]' + List of nodes from which initial bootstrapping can be done + :host: 'str' + Can be used to point to a startup node + :port: 'int' + Can be used to point to a startup node + :require_full_coverage: 'bool' + If set to True, as it is by default, all slots must be covered. + If set to False and not all slots are covered, the instance + creation will succeed only if 'cluster-require-full-coverage' + configuration is set to 'no' in all of the cluster's nodes. + Otherwise, RedisClusterException will be thrown. + :skip_full_coverage_check: 'bool' + If require_full_coverage is set to False, a check of + cluster-require-full-coverage config will be executed against all + nodes. Set skip_full_coverage_check to True to skip this check. + Useful for clusters without the CONFIG command (like ElastiCache) + :read_from_replicas: 'bool' + Enable read from replicas in READONLY mode. You can read possibly + stale data. + When set to true, read commands will be assigned between the + primary and its replications in a Round-Robin manner. + :cluster_error_retry_attempts: 'int' + Retry command execution attempts when encountering ClusterDownError + or ConnectionError + :retry_on_timeout: 'bool' + To specify a retry policy, first set `retry_on_timeout` to `True` + then set `retry` to a valid `Retry` object + :retry: 'Retry' + a `Retry` object + :**kwargs: + Extra arguments that will be sent into Redis instance when created + (See Official redis-py doc for supported kwargs + [https://github.com/andymccurdy/redis-py/blob/master/redis/client.py]) + Some kwargs are not supported and will raise a + RedisClusterException: + - db (Redis do not support database SELECT in cluster mode) + """ + log.info("Creating a new instance of RedisCluster client") + + if startup_nodes is None: + startup_nodes = [] + + if "db" in kwargs: + # Argument 'db' is not possible to use in cluster mode + raise RedisClusterException( + "Argument 'db' is not possible to use in cluster mode" + ) + + if retry_on_timeout: + kwargs.update({'retry_on_timeout': retry_on_timeout, + 'retry': retry}) + + # Get the startup node/s + from_url = False + if url is not None: + from_url = True + url_options = parse_url(url) + if "path" in url_options: + raise RedisClusterException( + "RedisCluster does not currently support Unix Domain " + "Socket connections") + if "db" in url_options and url_options["db"] != 0: + # Argument 'db' is not possible to use in cluster mode + raise RedisClusterException( + "A ``db`` querystring option can only be 0 in cluster mode" + ) + kwargs.update(url_options) + host = kwargs.get('host') + port = kwargs.get('port', port) + startup_nodes.append(ClusterNode(host, port)) + elif host is not None and port is not None: + startup_nodes.append(ClusterNode(host, port)) + elif len(startup_nodes) == 0: + # No startup node was provided + raise RedisClusterException( + "RedisCluster requires at least one node to discover the " + "cluster. Please provide one of the followings:\n" + "1. host and port, for example:\n" + " RedisCluster(host='localhost', port=6379)\n" + "2. list of startup nodes, for example:\n" + " RedisCluster(startup_nodes=[ClusterNode('localhost', 6379)," + " ClusterNode('localhost', 6378)])") + log.debug("startup_nodes : {0}".format(startup_nodes)) + # Update the connection arguments + # Whenever a new connection is established, RedisCluster's on_connect + # method should be run + # If the user passed on_connect function we'll save it and run it + # inside the RedisCluster.on_connect() function + self.user_on_connect_func = kwargs.pop("redis_connect_func", None) + kwargs.update({"redis_connect_func": self.on_connect}) + kwargs = cleanup_kwargs(**kwargs) + + self.encoder = Encoder( + kwargs.get("encoding", "utf-8"), + kwargs.get("encoding_errors", "strict"), + kwargs.get("decode_responses", False), + ) + self.cluster_error_retry_attempts = cluster_error_retry_attempts + self.command_flags = self.__class__.COMMAND_FLAGS.copy() + self.node_flags = self.__class__.NODE_FLAGS.copy() + self.read_from_replicas = read_from_replicas + self.reinitialize_counter = 0 + self.reinitialize_steps = reinitialize_steps + self.nodes_manager = None + self.nodes_manager = NodesManager( + startup_nodes=startup_nodes, + from_url=from_url, + require_full_coverage=require_full_coverage, + skip_full_coverage_check=skip_full_coverage_check, + **kwargs, + ) + + self.cluster_response_callbacks = CaseInsensitiveDict( + self.__class__.CLUSTER_COMMANDS_RESPONSE_CALLBACKS) + self.result_callbacks = CaseInsensitiveDict( + self.__class__.RESULT_CALLBACKS) + self.commands_parser = CommandsParser(self) + self._lock = threading.Lock() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def __del__(self): + self.close() + + def disconnect_connection_pools(self): + for node in self.get_nodes(): + if node.redis_connection: + try: + node.redis_connection.connection_pool.disconnect() + except OSError: + # Client was already disconnected. do nothing + pass + + @classmethod + def from_url(cls, url, **kwargs): + """ + Return a Redis client object configured from the given URL + + For example:: + + redis://[[username]:[password]]@localhost:6379/0 + rediss://[[username]:[password]]@localhost:6379/0 + unix://[[username]:[password]]@/path/to/socket.sock?db=0 + + Three URL schemes are supported: + + - `redis://` creates a TCP socket connection. See more at: + + - `rediss://` creates a SSL wrapped TCP socket connection. See more at: + + - ``unix://``: creates a Unix Domain Socket connection. + + The username, password, hostname, path and all querystring values + are passed through urllib.parse.unquote in order to replace any + percent-encoded values with their corresponding characters. + + There are several ways to specify a database number. The first value + found will be used: + 1. A ``db`` querystring option, e.g. redis://localhost?db=0 + 2. If using the redis:// or rediss:// schemes, the path argument + of the url, e.g. redis://localhost/0 + 3. A ``db`` keyword argument to this function. + + If none of these options are specified, the default db=0 is used. + + All querystring options are cast to their appropriate Python types. + Boolean arguments can be specified with string values "True"/"False" + or "Yes"/"No". Values that cannot be properly cast cause a + ``ValueError`` to be raised. Once parsed, the querystring arguments + and keyword arguments are passed to the ``ConnectionPool``'s + class initializer. In the case of conflicting arguments, querystring + arguments always win. + + """ + return cls(url=url, **kwargs) + + def on_connect(self, connection): + """ + Initialize the connection, authenticate and select a database and send + READONLY if it is set during object initialization. + """ + connection.set_parser(ClusterParser) + connection.on_connect() + + if self.read_from_replicas: + # Sending READONLY command to server to configure connection as + # readonly. Since each cluster node may change its server type due + # to a failover, we should establish a READONLY connection + # regardless of the server type. If this is a primary connection, + # READONLY would not affect executing write commands. + connection.send_command('READONLY') + if str_if_bytes(connection.read_response()) != 'OK': + raise ConnectionError('READONLY command failed') + + if self.user_on_connect_func is not None: + self.user_on_connect_func(connection) + + def get_redis_connection(self, node): + if not node.redis_connection: + with self._lock: + if not node.redis_connection: + self.nodes_manager.create_redis_connections([node]) + return node.redis_connection + + def get_node(self, host=None, port=None, node_name=None): + return self.nodes_manager.get_node(host, port, node_name) + + def get_primaries(self): + return self.nodes_manager.get_nodes_by_server_type(PRIMARY) + + def get_replicas(self): + return self.nodes_manager.get_nodes_by_server_type(REPLICA) + + def get_random_node(self): + return random.choice(list(self.nodes_manager.nodes_cache.values())) + + def get_nodes(self): + return list(self.nodes_manager.nodes_cache.values()) + + def get_node_from_key(self, key, replica=False): + """ + Get the node that holds the key's slot. + If replica set to True but the slot doesn't have any replicas, None is + returned. + """ + slot = self.keyslot(key) + slot_cache = self.nodes_manager.slots_cache.get(slot) + if slot_cache is None or len(slot_cache) == 0: + raise SlotNotCoveredError( + 'Slot "{0}" is not covered by the cluster.'.format(slot) + ) + if replica and len(self.nodes_manager.slots_cache[slot]) < 2: + return None + elif replica: + node_idx = 1 + else: + # primary + node_idx = 0 + + return slot_cache[node_idx] + + def get_default_node(self): + """ + Get the cluster's default node + """ + return self.nodes_manager.default_node + + def set_default_node(self, node): + """ + Set the default node of the cluster. + :param node: 'ClusterNode' + :return True if the default node was set, else False + """ + if node is None or self.get_node(node_name=node.name) is None: + log.info("The requested node does not exist in the cluster, so " + "the default node was not changed.") + return False + self.nodes_manager.default_node = node + log.info("Changed the default cluster node to {0}".format(node)) + return True + + def pubsub(self, node=None, host=None, port=None, **kwargs): + """ + Allows passing a ClusterNode, or host&port, to get a pubsub instance + connected to the specified node + """ + return ClusterPubSub(self, node=node, host=host, port=port, **kwargs) + + def pipeline(self, transaction=None, shard_hint=None): + """ + Cluster impl: + Pipelines do not work in cluster mode the same way they + do in normal mode. Create a clone of this object so + that simulating pipelines will work correctly. Each + command will be called directly when used and + when calling execute() will only return the result stack. + """ + if shard_hint: + raise RedisClusterException( + "shard_hint is deprecated in cluster mode") + + if transaction: + raise RedisClusterException( + "transaction is deprecated in cluster mode") + + return ClusterPipeline( + nodes_manager=self.nodes_manager, + startup_nodes=self.nodes_manager.startup_nodes, + result_callbacks=self.result_callbacks, + cluster_response_callbacks=self.cluster_response_callbacks, + cluster_error_retry_attempts=self.cluster_error_retry_attempts, + read_from_replicas=self.read_from_replicas, + reinitialize_steps=self.reinitialize_steps + ) + + def _determine_nodes(self, *args, **kwargs): + command = args[0] + nodes_flag = kwargs.pop("nodes_flag", None) + if nodes_flag is not None: + # nodes flag passed by the user + command_flag = nodes_flag + else: + # get the nodes group for this command if it was predefined + command_flag = self.command_flags.get(command) + if command_flag: + log.debug("Target node/s for {0}: {1}". + format(command, command_flag)) + if command_flag == self.__class__.RANDOM: + # return a random node + return [self.get_random_node()] + elif command_flag == self.__class__.PRIMARIES: + # return all primaries + return self.get_primaries() + elif command_flag == self.__class__.REPLICAS: + # return all replicas + return self.get_replicas() + elif command_flag == self.__class__.ALL_NODES: + # return all nodes + return self.get_nodes() + elif command_flag == self.__class__.DEFAULT_NODE: + # return the cluster's default node + return [self.nodes_manager.default_node] + else: + # get the node that holds the key's slot + slot = self.determine_slot(*args) + node = self.nodes_manager.get_node_from_slot( + slot, self.read_from_replicas and command in READ_COMMANDS) + log.debug("Target for {0}: slot {1}".format(args, slot)) + return [node] + + def _should_reinitialized(self): + # In order not to reinitialize the cluster, the user can set + # reinitialize_steps to 0. + if self.reinitialize_steps == 0: + return False + else: + return self.reinitialize_counter % self.reinitialize_steps == 0 + + def keyslot(self, key): + """ + Calculate keyslot for a given key. + See Keys distribution model in https://redis.io/topics/cluster-spec + """ + k = self.encoder.encode(key) + return key_slot(k) + + def _get_command_keys(self, *args): + """ + Get the keys in the command. If the command has no keys in in, None is + returned. + """ + redis_conn = self.get_default_node().redis_connection + return self.commands_parser.get_keys(redis_conn, *args) + + def determine_slot(self, *args): + """ + Figure out what slot based on command and args + """ + if self.command_flags.get(args[0]) == SLOT_ID: + # The command contains the slot ID + return args[1] + + # Get the keys in the command + keys = self._get_command_keys(*args) + if keys is None or len(keys) == 0: + raise RedisClusterException( + "No way to dispatch this command to Redis Cluster. " + "Missing key.\nYou can execute the command by specifying " + "target nodes.\nCommand: {0}".format(args) + ) + + if len(keys) > 1: + # multi-key command, we need to make sure all keys are mapped to + # the same slot + slots = {self.keyslot(key) for key in keys} + if len(slots) != 1: + raise RedisClusterException("{0} - all keys must map to the " + "same key slot".format(args[0])) + return slots.pop() + else: + # single key command + return self.keyslot(keys[0]) + + def reinitialize_caches(self): + self.nodes_manager.initialize() + + def _is_nodes_flag(self, target_nodes): + return isinstance(target_nodes, str) \ + and target_nodes in self.node_flags + + def _parse_target_nodes(self, target_nodes): + if isinstance(target_nodes, list): + nodes = target_nodes + elif isinstance(target_nodes, ClusterNode): + # Supports passing a single ClusterNode as a variable + nodes = [target_nodes] + elif isinstance(target_nodes, dict): + # Supports dictionaries of the format {node_name: node}. + # It enables to execute commands with multi nodes as follows: + # rc.cluster_save_config(rc.get_primaries()) + nodes = target_nodes.values() + else: + raise TypeError("target_nodes type can be one of the " + "followings: node_flag (PRIMARIES, " + "REPLICAS, RANDOM, ALL_NODES)," + "ClusterNode, list, or " + "dict. The passed type is {0}". + format(type(target_nodes))) + return nodes + + def execute_command(self, *args, **kwargs): + """ + Wrapper for ClusterDownError and ConnectionError error handling. + + It will try the number of times specified by the config option + "self.cluster_error_retry_attempts" which defaults to 3 unless manually + configured. + + If it reaches the number of times, the command will raise the exception + + Key argument :target_nodes: can be passed with the following types: + nodes_flag: PRIMARIES, REPLICAS, ALL_NODES, RANDOM + ClusterNode + list + dict + """ + target_nodes_specified = False + target_nodes = kwargs.pop("target_nodes", None) + if target_nodes is not None and not self._is_nodes_flag(target_nodes): + target_nodes = self._parse_target_nodes(target_nodes) + target_nodes_specified = True + # If ClusterDownError/ConnectionError were thrown, the nodes + # and slots cache were reinitialized. We will retry executing the + # command with the updated cluster setup only when the target nodes + # can be determined again with the new cache tables. Therefore, + # when target nodes were passed to this function, we cannot retry + # the command execution since the nodes may not be valid anymore + # after the tables were reinitialized. So in case of passed target + # nodes, retry_attempts will be set to 1. + retry_attempts = 1 if target_nodes_specified else \ + self.cluster_error_retry_attempts + exception = None + for _ in range(0, retry_attempts): + try: + res = {} + if not target_nodes_specified: + # Determine the nodes to execute the command on + target_nodes = self._determine_nodes( + *args, **kwargs, nodes_flag=target_nodes) + if not target_nodes: + raise RedisClusterException( + "No targets were found to execute" + " {} command on".format(args)) + for node in target_nodes: + res[node.name] = self._execute_command( + node, *args, **kwargs) + # Return the processed result + return self._process_result(args[0], res, **kwargs) + except (ClusterDownError, ConnectionError) as e: + # The nodes and slots cache were reinitialized. + # Try again with the new cluster setup. All other errors + # should be raised. + exception = e + + # If it fails the configured number of times then raise exception back + # to caller of this method + raise exception + + def _execute_command(self, target_node, *args, **kwargs): + """ + Send a command to a node in the cluster + """ + command = args[0] + redis_node = None + connection = None + redirect_addr = None + asking = False + moved = False + ttl = int(self.RedisClusterRequestTTL) + connection_error_retry_counter = 0 + + while ttl > 0: + ttl -= 1 + try: + if asking: + target_node = self.get_node(node_name=redirect_addr) + elif moved: + # MOVED occurred and the slots cache was updated, + # refresh the target node + slot = self.determine_slot(*args) + target_node = self.nodes_manager. \ + get_node_from_slot(slot, self.read_from_replicas and + command in READ_COMMANDS) + moved = False + + log.debug("Executing command {0} on target node: {1} {2}". + format(command, target_node.server_type, + target_node.name)) + redis_node = self.get_redis_connection(target_node) + connection = get_connection(redis_node, *args, **kwargs) + if asking: + connection.send_command("ASKING") + redis_node.parse_response(connection, "ASKING", **kwargs) + asking = False + + connection.send_command(*args) + response = redis_node.parse_response(connection, command, + **kwargs) + if command in self.cluster_response_callbacks: + response = self.cluster_response_callbacks[command]( + response, **kwargs) + return response + + except (RedisClusterException, BusyLoadingError): + log.exception("RedisClusterException || BusyLoadingError") + raise + except ConnectionError: + log.exception("ConnectionError") + # ConnectionError can also be raised if we couldn't get a + # connection from the pool before timing out, so check that + # this is an actual connection before attempting to disconnect. + if connection is not None: + connection.disconnect() + connection_error_retry_counter += 1 + + # Give the node 0.25 seconds to get back up and retry again + # with same node and configuration. After 5 attempts then try + # to reinitialize the cluster and see if the nodes + # configuration has changed or not + if connection_error_retry_counter < 5: + time.sleep(0.25) + else: + # Hard force of reinitialize of the node/slots setup + # and try again with the new setup + self.nodes_manager.initialize() + raise + except TimeoutError: + log.exception("TimeoutError") + if connection is not None: + connection.disconnect() + + if ttl < self.RedisClusterRequestTTL / 2: + time.sleep(0.05) + except MovedError as e: + # First, we will try to patch the slots/nodes cache with the + # redirected node output and try again. If MovedError exceeds + # 'reinitialize_steps' number of times, we will force + # reinitializing the tables, and then try again. + # 'reinitialize_steps' counter will increase faster when the + # same client object is shared between multiple threads. To + # reduce the frequency you can set this variable in the + # RedisCluster constructor. + log.exception("MovedError") + self.reinitialize_counter += 1 + if self._should_reinitialized(): + self.nodes_manager.initialize() + else: + self.nodes_manager.update_moved_exception(e) + moved = True + except TryAgainError: + log.exception("TryAgainError") + + if ttl < self.RedisClusterRequestTTL / 2: + time.sleep(0.05) + except AskError as e: + log.exception("AskError") + + redirect_addr = get_node_name(host=e.host, port=e.port) + asking = True + except ClusterDownError as e: + log.exception("ClusterDownError") + # ClusterDownError can occur during a failover and to get + # self-healed, we will try to reinitialize the cluster layout + # and retry executing the command + time.sleep(0.05) + self.nodes_manager.initialize() + raise e + except ResponseError as e: + message = e.__str__() + log.exception("ResponseError: {0}".format(message)) + raise e + except BaseException as e: + log.exception("BaseException") + if connection: + connection.disconnect() + raise e + finally: + if connection is not None: + redis_node.connection_pool.release(connection) + + raise ClusterError("TTL exhausted.") + + def close(self): + try: + with self._lock: + if self.nodes_manager: + self.nodes_manager.close() + except AttributeError: + # RedisCluster's __init__ can fail before nodes_manager is set + pass + + def _process_result(self, command, res, **kwargs): + """ + Process the result of the executed command. + The function would return a dict or a single value. + + :type command: str + :type res: dict + + `res` should be in the following format: + Dict + """ + if command in self.result_callbacks: + return self.result_callbacks[command](command, res, **kwargs) + elif len(res) == 1: + # When we execute the command on a single node, we can + # remove the dictionary and return a single response + return list(res.values())[0] + else: + return res + + +class ClusterNode(object): + def __init__(self, host, port, server_type=None, redis_connection=None): + if host == 'localhost': + host = socket.gethostbyname(host) + + self.host = host + self.port = port + self.name = get_node_name(host, port) + self.server_type = server_type + self.redis_connection = redis_connection + + def __repr__(self): + return '[host={0},port={1},' \ + 'name={2},server_type={3},redis_connection={4}]' \ + .format(self.host, + self.port, + self.name, + self.server_type, + self.redis_connection) + + def __eq__(self, obj): + return isinstance(obj, ClusterNode) and obj.name == self.name + + +class LoadBalancer: + """ + Round-Robin Load Balancing + """ + + def __init__(self, start_index=0): + self.primary_to_idx = {} + self.start_index = start_index + + def get_server_index(self, primary, list_size): + server_index = self.primary_to_idx.setdefault(primary, + self.start_index) + # Update the index + self.primary_to_idx[primary] = (server_index + 1) % list_size + return server_index + + def reset(self): + self.primary_to_idx.clear() + + +class NodesManager: + def __init__(self, startup_nodes, from_url=False, + require_full_coverage=True, skip_full_coverage_check=False, + lock=None, **kwargs): + self.nodes_cache = {} + self.slots_cache = {} + self.startup_nodes = {} + self.default_node = None + self.populate_startup_nodes(startup_nodes) + self.from_url = from_url + self._require_full_coverage = require_full_coverage + self._skip_full_coverage_check = skip_full_coverage_check + self._moved_exception = None + self.connection_kwargs = kwargs + self.read_load_balancer = LoadBalancer() + if lock is None: + lock = threading.Lock() + self._lock = lock + self.initialize() + + def get_node(self, host=None, port=None, node_name=None): + """ + Get the requested node from the cluster's nodes. + nodes. + :return: ClusterNode if the node exists, else None + """ + if host and port: + # the user passed host and port + if host == "localhost": + host = socket.gethostbyname(host) + return self.nodes_cache.get(get_node_name(host=host, port=port)) + elif node_name: + return self.nodes_cache.get(node_name) + else: + log.error( + "get_node requires one of the following: " + "1. node name " + "2. host and port" + ) + return None + + def update_moved_exception(self, exception): + self._moved_exception = exception + + def _update_moved_slots(self): + """ + Update the slot's node with the redirected one + """ + e = self._moved_exception + redirected_node = self.get_node(host=e.host, port=e.port) + if redirected_node is not None: + # The node already exists + if redirected_node.server_type is not PRIMARY: + # Update the node's server type + redirected_node.server_type = PRIMARY + else: + # This is a new node, we will add it to the nodes cache + redirected_node = ClusterNode(e.host, e.port, PRIMARY) + self.nodes_cache[redirected_node.name] = redirected_node + if redirected_node in self.slots_cache[e.slot_id]: + # The MOVED error resulted from a failover, and the new slot owner + # had previously been a replica. + old_primary = self.slots_cache[e.slot_id][0] + # Update the old primary to be a replica and add it to the end of + # the slot's node list + old_primary.server_type = REPLICA + self.slots_cache[e.slot_id].append(old_primary) + # Remove the old replica, which is now a primary, from the slot's + # node list + self.slots_cache[e.slot_id].remove(redirected_node) + # Override the old primary with the new one + self.slots_cache[e.slot_id][0] = redirected_node + if self.default_node == old_primary: + # Update the default node with the new primary + self.default_node = redirected_node + else: + # The new slot owner is a new server, or a server from a different + # shard. We need to remove all current nodes from the slot's list + # (including replications) and add just the new node. + self.slots_cache[e.slot_id] = [redirected_node] + # Reset moved_exception + self._moved_exception = None + + def get_node_from_slot(self, slot, read_from_replicas=False, + server_type=None): + """ + Gets a node that servers this hash slot + """ + if self._moved_exception: + with self._lock: + if self._moved_exception: + self._update_moved_slots() + + if self.slots_cache.get(slot) is None or \ + len(self.slots_cache[slot]) == 0: + raise SlotNotCoveredError( + 'Slot "{0}" not covered by the cluster. ' + '"require_full_coverage={1}"'.format( + slot, self._require_full_coverage) + ) + + if read_from_replicas is True: + # get the server index in a Round-Robin manner + primary_name = self.slots_cache[slot][0].name + node_idx = self.read_load_balancer.get_server_index( + primary_name, len(self.slots_cache[slot])) + elif ( + server_type is None + or server_type == PRIMARY + or len(self.slots_cache[slot]) == 1 + ): + # return a primary + node_idx = 0 + else: + # return a replica + # randomly choose one of the replicas + node_idx = random.randint( + 1, len(self.slots_cache[slot]) - 1) + + return self.slots_cache[slot][node_idx] + + def get_nodes_by_server_type(self, server_type): + """ + Get all nodes with the specified server type + :param server_type: 'primary' or 'replica' + :return: list of ClusterNode + """ + return [ + node + for node in self.nodes_cache.values() + if node.server_type == server_type + ] + + def populate_startup_nodes(self, nodes): + """ + Populate all startup nodes and filters out any duplicates + """ + for n in nodes: + self.startup_nodes[n.name] = n + + def cluster_require_full_coverage(self, cluster_nodes): + """ + if exists 'cluster-require-full-coverage no' config on redis servers, + then even all slots are not covered, cluster still will be able to + respond + """ + + def node_require_full_coverage(node): + try: + return ("yes" in node.redis_connection.config_get( + "cluster-require-full-coverage").values() + ) + except ConnectionError: + return False + except Exception as e: + raise RedisClusterException( + 'ERROR sending "config get cluster-require-full-coverage"' + ' command to redis server: {0}, {1}'.format(node.name, e) + ) + + # at least one node should have cluster-require-full-coverage yes + return any(node_require_full_coverage(node) + for node in cluster_nodes.values()) + + def check_slots_coverage(self, slots_cache): + # Validate if all slots are covered or if we should try next + # startup node + for i in range(0, REDIS_CLUSTER_HASH_SLOTS): + if i not in slots_cache: + return False + return True + + def create_redis_connections(self, nodes): + """ + This function will create a redis connection to all nodes in :nodes: + """ + for node in nodes: + if node.redis_connection is None: + node.redis_connection = self.create_redis_node( + host=node.host, + port=node.port, + **self.connection_kwargs, + ) + + def create_redis_node(self, host, port, **kwargs): + if self.from_url: + # Create a redis node with a costumed connection pool + kwargs.update({"host": host}) + kwargs.update({"port": port}) + r = Redis(connection_pool=ConnectionPool(**kwargs)) + else: + r = Redis( + host=host, + port=port, + **kwargs + ) + return r + + def initialize(self): + """ + Initializes the nodes cache, slots cache and redis connections. + :startup_nodes: + Responsible for discovering other nodes in the cluster + """ + log.debug("Initializing the nodes' topology of the cluster") + self.reset() + tmp_nodes_cache = {} + tmp_slots = {} + disagreements = [] + startup_nodes_reachable = False + kwargs = self.connection_kwargs + for startup_node in self.startup_nodes.values(): + try: + if startup_node.redis_connection: + r = startup_node.redis_connection + else: + # Create a new Redis connection and let Redis decode the + # responses so we won't need to handle that + copy_kwargs = copy.deepcopy(kwargs) + copy_kwargs.update({"decode_responses": True, + "encoding": "utf-8"}) + r = self.create_redis_node( + startup_node.host, startup_node.port, **copy_kwargs) + self.startup_nodes[startup_node.name].redis_connection = r + cluster_slots = r.execute_command("CLUSTER SLOTS") + startup_nodes_reachable = True + except (ConnectionError, TimeoutError) as e: + msg = e.__str__ + log.exception('An exception occurred while trying to' + ' initialize the cluster using the seed node' + ' {0}:\n{1}'.format(startup_node.name, msg)) + continue + except ResponseError as e: + log.exception( + 'ReseponseError sending "cluster slots" to redis server') + + # Isn't a cluster connection, so it won't parse these + # exceptions automatically + message = e.__str__() + if "CLUSTERDOWN" in message or "MASTERDOWN" in message: + continue + else: + raise RedisClusterException( + 'ERROR sending "cluster slots" command to redis ' + 'server: {0}. error: {1}'.format( + startup_node, message) + ) + except Exception as e: + message = e.__str__() + raise RedisClusterException( + 'ERROR sending "cluster slots" command to redis ' + 'server: {0}. error: {1}'.format( + startup_node, message) + ) + + # CLUSTER SLOTS command results in the following output: + # [[slot_section[from_slot,to_slot,master,replica1,...,replicaN]]] + # where each node contains the following list: [IP, port, node_id] + # Therefore, cluster_slots[0][2][0] will be the IP address of the + # primary node of the first slot section. + # If there's only one server in the cluster, its ``host`` is '' + # Fix it to the host in startup_nodes + if (len(cluster_slots) == 1 + and len(cluster_slots[0][2][0]) == 0 + and len(self.startup_nodes) == 1): + cluster_slots[0][2][0] = startup_node.host + + for slot in cluster_slots: + primary_node = slot[2] + host = primary_node[0] + if host == "": + host = startup_node.host + port = int(primary_node[1]) + + target_node = tmp_nodes_cache.get(get_node_name(host, port)) + if target_node is None: + target_node = ClusterNode(host, port, PRIMARY) + # add this node to the nodes cache + tmp_nodes_cache[target_node.name] = target_node + + for i in range(int(slot[0]), int(slot[1]) + 1): + if i not in tmp_slots: + tmp_slots[i] = [] + tmp_slots[i].append(target_node) + replica_nodes = [slot[j] for j in range(3, len(slot))] + + for replica_node in replica_nodes: + host = replica_node[0] + port = replica_node[1] + + target_replica_node = tmp_nodes_cache.get( + get_node_name(host, port)) + if target_replica_node is None: + target_replica_node = ClusterNode( + host, port, REPLICA) + tmp_slots[i].append(target_replica_node) + # add this node to the nodes cache + tmp_nodes_cache[ + target_replica_node.name + ] = target_replica_node + else: + # Validate that 2 nodes want to use the same slot cache + # setup + if tmp_slots[i][0].name != target_node.name: + disagreements.append( + '{0} vs {1} on slot: {2}'.format( + tmp_slots[i][0].name, target_node.name, i) + ) + + if len(disagreements) > 5: + raise RedisClusterException( + 'startup_nodes could not agree on a valid' + ' slots cache: {0}'.format( + ", ".join(disagreements)) + ) + + if not startup_nodes_reachable: + raise RedisClusterException( + "Redis Cluster cannot be connected. Please provide at least " + "one reachable node. " + ) + + # Create Redis connections to all nodes + self.create_redis_connections(list(tmp_nodes_cache.values())) + + fully_covered = self.check_slots_coverage(tmp_slots) + # Check if the slots are not fully covered + if not fully_covered and self._require_full_coverage: + # Despite the requirement that the slots be covered, there + # isn't a full coverage + raise RedisClusterException( + 'All slots are not covered after query all startup_nodes.' + ' {0} of {1} covered...'.format( + len(self.slots_cache), REDIS_CLUSTER_HASH_SLOTS) + ) + elif not fully_covered and not self._require_full_coverage: + # The user set require_full_coverage to False. + # In case of full coverage requirement in the cluster's Redis + # configurations, we will raise an exception. Otherwise, we may + # continue with partial coverage. + # see Redis Cluster configuration parameters in + # https://redis.io/topics/cluster-tutorial + if not self._skip_full_coverage_check and \ + self.cluster_require_full_coverage(tmp_nodes_cache): + raise RedisClusterException( + 'Not all slots are covered but the cluster\'s ' + 'configuration requires full coverage. Set ' + 'cluster-require-full-coverage configuration to no on ' + 'all of the cluster nodes if you wish the cluster to ' + 'be able to serve without being fully covered.' + ' {0} of {1} covered...'.format( + len(self.slots_cache), REDIS_CLUSTER_HASH_SLOTS) + ) + + # Set the tmp variables to the real variables + self.nodes_cache = tmp_nodes_cache + self.slots_cache = tmp_slots + # Set the default node + self.default_node = self.get_nodes_by_server_type(PRIMARY)[0] + # Populate the startup nodes with all discovered nodes + self.populate_startup_nodes(self.nodes_cache.values()) + + def close(self): + self.default_node = None + for node in self.nodes_cache.values(): + if node.redis_connection: + node.redis_connection.close() + + def reset(self): + try: + self.read_load_balancer.reset() + except TypeError: + # The read_load_balancer is None, do nothing + pass + + +class ClusterPubSub(PubSub): + """ + Wrapper for PubSub class. + + IMPORTANT: before using ClusterPubSub, read about the known limitations + with pubsub in Cluster mode and learn how to workaround them: + https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html + """ + + def __init__(self, redis_cluster, node=None, host=None, port=None, + **kwargs): + """ + When a pubsub instance is created without specifying a node, a single + node will be transparently chosen for the pubsub connection on the + first command execution. The node will be determined by: + 1. Hashing the channel name in the request to find its keyslot + 2. Selecting a node that handles the keyslot: If read_from_replicas is + set to true, a replica can be selected. + + :type redis_cluster: RedisCluster + :type node: ClusterNode + :type host: str + :type port: int + """ + log.info("Creating new instance of ClusterPubSub") + self.node = None + self.set_pubsub_node(redis_cluster, node, host, port) + connection_pool = None if self.node is None else \ + redis_cluster.get_redis_connection(self.node).connection_pool + self.cluster = redis_cluster + super().__init__(**kwargs, connection_pool=connection_pool, + encoder=redis_cluster.encoder) + + def set_pubsub_node(self, cluster, node=None, host=None, port=None): + """ + The pubsub node will be set according to the passed node, host and port + When none of the node, host, or port are specified - the node is set + to None and will be determined by the keyslot of the channel in the + first command to be executed. + RedisClusterException will be thrown if the passed node does not exist + in the cluster. + If host is passed without port, or vice versa, a DataError will be + thrown. + :type cluster: RedisCluster + :type node: ClusterNode + :type host: str + :type port: int + """ + if node is not None: + # node is passed by the user + self._raise_on_invalid_node(cluster, node, node.host, node.port) + pubsub_node = node + elif host is not None and port is not None: + # host and port passed by the user + node = cluster.get_node(host=host, port=port) + self._raise_on_invalid_node(cluster, node, host, port) + pubsub_node = node + elif any([host, port]) is True: + # only 'host' or 'port' passed + raise DataError('Passing a host requires passing a port, ' + 'and vice versa') + else: + # nothing passed by the user. set node to None + pubsub_node = None + + self.node = pubsub_node + + def get_pubsub_node(self): + """ + Get the node that is being used as the pubsub connection + """ + return self.node + + def _raise_on_invalid_node(self, redis_cluster, node, host, port): + """ + Raise a RedisClusterException if the node is None or doesn't exist in + the cluster. + """ + if node is None or redis_cluster.get_node(node_name=node.name) is None: + raise RedisClusterException( + "Node {0}:{1} doesn't exist in the cluster" + .format(host, port)) + + def execute_command(self, *args, **kwargs): + """ + Execute a publish/subscribe command. + + Taken code from redis-py and tweak to make it work within a cluster. + """ + # NOTE: don't parse the response in this function -- it could pull a + # legitimate message off the stack if the connection is already + # subscribed to one or more channels + + if self.connection is None: + if self.connection_pool is None: + if len(args) > 1: + # Hash the first channel and get one of the nodes holding + # this slot + channel = args[1] + slot = self.cluster.keyslot(channel) + node = self.cluster.nodes_manager. \ + get_node_from_slot(slot, self.cluster. + read_from_replicas) + else: + # Get a random node + node = self.cluster.get_random_node() + self.node = node + redis_connection = self.cluster.get_redis_connection(node) + self.connection_pool = redis_connection.connection_pool + self.connection = self.connection_pool.get_connection( + 'pubsub', + self.shard_hint + ) + # register a callback that re-subscribes to any channels we + # were listening to when we were disconnected + self.connection.register_connect_callback(self.on_connect) + connection = self.connection + self._execute(connection, connection.send_command, *args) + + def get_redis_connection(self): + """ + Get the Redis connection of the pubsub connected node. + """ + if self.node is not None: + return self.node.redis_connection + + +ERRORS_ALLOW_RETRY = (ConnectionError, TimeoutError, + MovedError, AskError, TryAgainError) + + +class ClusterPipeline(RedisCluster): + """ + Support for Redis pipeline + in cluster mode + """ + + def __init__(self, nodes_manager, result_callbacks=None, + cluster_response_callbacks=None, startup_nodes=None, + read_from_replicas=False, cluster_error_retry_attempts=3, + reinitialize_steps=10, **kwargs): + """ + """ + log.info("Creating new instance of ClusterPipeline") + self.command_stack = [] + self.nodes_manager = nodes_manager + self.refresh_table_asap = False + self.result_callbacks = (result_callbacks or + self.__class__.RESULT_CALLBACKS.copy()) + self.startup_nodes = startup_nodes if startup_nodes else [] + self.read_from_replicas = read_from_replicas + self.command_flags = self.__class__.COMMAND_FLAGS.copy() + self.cluster_response_callbacks = cluster_response_callbacks + self.cluster_error_retry_attempts = cluster_error_retry_attempts + self.reinitialize_counter = 0 + self.reinitialize_steps = reinitialize_steps + self.encoder = Encoder( + kwargs.get("encoding", "utf-8"), + kwargs.get("encoding_errors", "strict"), + kwargs.get("decode_responses", False), + ) + + # The commands parser refers to the parent + # so that we don't push the COMMAND command + # onto the stack + self.commands_parser = CommandsParser(super()) + + def __repr__(self): + """ + """ + return "{0}".format(type(self).__name__) + + def __enter__(self): + """ + """ + return self + + def __exit__(self, exc_type, exc_value, traceback): + """ + """ + self.reset() + + def __del__(self): + try: + self.reset() + except Exception: + pass + + def __len__(self): + """ + """ + return len(self.command_stack) + + def __nonzero__(self): + "Pipeline instances should always evaluate to True on Python 2.7" + return True + + def __bool__(self): + "Pipeline instances should always evaluate to True on Python 3+" + return True + + def execute_command(self, *args, **kwargs): + """ + Wrapper function for pipeline_execute_command + """ + return self.pipeline_execute_command(*args, **kwargs) + + def pipeline_execute_command(self, *args, **options): + """ + Appends the executed command to the pipeline's command stack + """ + self.command_stack.append( + PipelineCommand(args, options, len(self.command_stack))) + return self + + def raise_first_error(self, stack): + """ + Raise the first exception on the stack + """ + for c in stack: + r = c.result + if isinstance(r, Exception): + self.annotate_exception(r, c.position + 1, c.args) + raise r + + def annotate_exception(self, exception, number, command): + """ + Provides extra context to the exception prior to it being handled + """ + cmd = ' '.join(map(safe_str, command)) + msg = 'Command # %d (%s) of pipeline caused error: %s' % ( + number, cmd, exception.args[0]) + exception.args = (msg,) + exception.args[1:] + + def execute(self, raise_on_error=True): + """ + Execute all the commands in the current pipeline + """ + stack = self.command_stack + try: + return self.send_cluster_commands(stack, raise_on_error) + finally: + self.reset() + + def reset(self): + """ + Reset back to empty pipeline. + """ + self.command_stack = [] + + self.scripts = set() + + # TODO: Implement + # make sure to reset the connection state in the event that we were + # watching something + # if self.watching and self.connection: + # try: + # # call this manually since our unwatch or + # # immediate_execute_command methods can call reset() + # self.connection.send_command('UNWATCH') + # self.connection.read_response() + # except ConnectionError: + # # disconnect will also remove any previous WATCHes + # self.connection.disconnect() + + # clean up the other instance attributes + self.watching = False + self.explicit_transaction = False + + # TODO: Implement + # we can safely return the connection to the pool here since we're + # sure we're no longer WATCHing anything + # if self.connection: + # self.connection_pool.release(self.connection) + # self.connection = None + + def send_cluster_commands(self, stack, + raise_on_error=True, allow_redirections=True): + """ + Wrapper for CLUSTERDOWN error handling. + + If the cluster reports it is down it is assumed that: + - connection_pool was disconnected + - connection_pool was reseted + - refereh_table_asap set to True + + It will try the number of times specified by + the config option "self.cluster_error_retry_attempts" + which defaults to 3 unless manually configured. + + If it reaches the number of times, the command will + raises ClusterDownException. + """ + if not stack: + return [] + + for _ in range(0, self.cluster_error_retry_attempts): + try: + return self._send_cluster_commands( + stack, + raise_on_error=raise_on_error, + allow_redirections=allow_redirections, + ) + except ClusterDownError: + # Try again with the new cluster setup. All other errors + # should be raised. + pass + + # If it fails the configured number of times then raise + # exception back to caller of this method + raise ClusterDownError( + "CLUSTERDOWN error. Unable to rebuild the cluster") + + def _send_cluster_commands(self, stack, + raise_on_error=True, + allow_redirections=True): + """ + Send a bunch of cluster commands to the redis cluster. + + `allow_redirections` If the pipeline should follow + `ASK` & `MOVED` responses automatically. If set + to false it will raise RedisClusterException. + """ + # the first time sending the commands we send all of + # the commands that were queued up. + # if we have to run through it again, we only retry + # the commands that failed. + attempt = sorted(stack, key=lambda x: x.position) + + # build a list of node objects based on node names we need to + nodes = {} + + # as we move through each command that still needs to be processed, + # we figure out the slot number that command maps to, then from + # the slot determine the node. + for c in attempt: + # refer to our internal node -> slot table that + # tells us where a given + # command should route to. + slot = self.determine_slot(*c.args) + node = self.nodes_manager.get_node_from_slot( + slot, self.read_from_replicas and c.args[0] in READ_COMMANDS) + + # now that we know the name of the node + # ( it's just a string in the form of host:port ) + # we can build a list of commands for each node. + node_name = node.name + if node_name not in nodes: + redis_node = self.get_redis_connection(node) + connection = get_connection(redis_node, c.args) + nodes[node_name] = NodeCommands(redis_node.parse_response, + redis_node.connection_pool, + connection) + + nodes[node_name].append(c) + + # send the commands in sequence. + # we write to all the open sockets for each node first, + # before reading anything + # this allows us to flush all the requests out across the + # network essentially in parallel + # so that we can read them all in parallel as they come back. + # we dont' multiplex on the sockets as they come available, + # but that shouldn't make too much difference. + node_commands = nodes.values() + for n in node_commands: + n.write() + + for n in node_commands: + n.read() + + # release all of the redis connections we allocated earlier + # back into the connection pool. + # we used to do this step as part of a try/finally block, + # but it is really dangerous to + # release connections back into the pool if for some + # reason the socket has data still left in it + # from a previous operation. The write and + # read operations already have try/catch around them for + # all known types of errors including connection + # and socket level errors. + # So if we hit an exception, something really bad + # happened and putting any oF + # these connections back into the pool is a very bad idea. + # the socket might have unread buffer still sitting in it, + # and then the next time we read from it we pass the + # buffered result back from a previous command and + # every single request after to that connection will always get + # a mismatched result. + for n in nodes.values(): + n.connection_pool.release(n.connection) + + # if the response isn't an exception it is a + # valid response from the node + # we're all done with that command, YAY! + # if we have more commands to attempt, we've run into problems. + # collect all the commands we are allowed to retry. + # (MOVED, ASK, or connection errors or timeout errors) + attempt = sorted([c for c in attempt + if isinstance(c.result, ERRORS_ALLOW_RETRY)], + key=lambda x: x.position) + if attempt and allow_redirections: + # RETRY MAGIC HAPPENS HERE! + # send these remaing comamnds one at a time using `execute_command` + # in the main client. This keeps our retry logic + # in one place mostly, + # and allows us to be more confident in correctness of behavior. + # at this point any speed gains from pipelining have been lost + # anyway, so we might as well make the best + # attempt to get the correct behavior. + # + # The client command will handle retries for each + # individual command sequentially as we pass each + # one into `execute_command`. Any exceptions + # that bubble out should only appear once all + # retries have been exhausted. + # + # If a lot of commands have failed, we'll be setting the + # flag to rebuild the slots table from scratch. + # So MOVED errors should correct themselves fairly quickly. + msg = 'An exception occurred during pipeline execution. ' \ + 'args: {0}, error: {1} {2}'.\ + format(attempt[-1].args, + type(attempt[-1].result).__name__, + str(attempt[-1].result)) + log.exception(msg) + self.reinitialize_counter += 1 + if self._should_reinitialized(): + self.nodes_manager.initialize() + for c in attempt: + try: + # send each command individually like we + # do in the main client. + c.result = super(ClusterPipeline, self). \ + execute_command(*c.args, **c.options) + except RedisError as e: + c.result = e + + # turn the response back into a simple flat array that corresponds + # to the sequence of commands issued in the stack in pipeline.execute() + response = [c.result for c in sorted(stack, key=lambda x: x.position)] + + if raise_on_error: + self.raise_first_error(stack) + + return response + + def _fail_on_redirect(self, allow_redirections): + """ + """ + if not allow_redirections: + raise RedisClusterException( + "ASK & MOVED redirection not allowed in this pipeline") + + def eval(self): + """ + """ + raise RedisClusterException("method eval() is not implemented") + + def multi(self): + """ + """ + raise RedisClusterException("method multi() is not implemented") + + def immediate_execute_command(self, *args, **options): + """ + """ + raise RedisClusterException( + "method immediate_execute_command() is not implemented") + + def _execute_transaction(self, *args, **kwargs): + """ + """ + raise RedisClusterException( + "method _execute_transaction() is not implemented") + + def load_scripts(self): + """ + """ + raise RedisClusterException( + "method load_scripts() is not implemented") + + def watch(self, *names): + """ + """ + raise RedisClusterException("method watch() is not implemented") + + def unwatch(self): + """ + """ + raise RedisClusterException("method unwatch() is not implemented") + + def script_load_for_pipeline(self, *args, **kwargs): + """ + """ + raise RedisClusterException( + "method script_load_for_pipeline() is not implemented") + + def delete(self, *names): + """ + "Delete a key specified by ``names``" + """ + if len(names) != 1: + raise RedisClusterException( + "deleting multiple keys is not " + "implemented in pipeline command") + + return self.execute_command('DEL', names[0]) + + +def block_pipeline_command(func): + """ + Prints error because some pipelined commands should + be blocked when running in cluster-mode + """ + + def inner(*args, **kwargs): + raise RedisClusterException( + "ERROR: Calling pipelined function {0} is blocked when " + "running redis in cluster mode...".format(func.__name__)) + + return inner + + +# Blocked pipeline commands +ClusterPipeline.bitop = block_pipeline_command(RedisCluster.bitop) +ClusterPipeline.brpoplpush = block_pipeline_command(RedisCluster.brpoplpush) +ClusterPipeline.client_getname = \ + block_pipeline_command(RedisCluster.client_getname) +ClusterPipeline.client_list = block_pipeline_command(RedisCluster.client_list) +ClusterPipeline.client_setname = \ + block_pipeline_command(RedisCluster.client_setname) +ClusterPipeline.config_set = block_pipeline_command(RedisCluster.config_set) +ClusterPipeline.dbsize = block_pipeline_command(RedisCluster.dbsize) +ClusterPipeline.flushall = block_pipeline_command(RedisCluster.flushall) +ClusterPipeline.flushdb = block_pipeline_command(RedisCluster.flushdb) +ClusterPipeline.keys = block_pipeline_command(RedisCluster.keys) +ClusterPipeline.mget = block_pipeline_command(RedisCluster.mget) +ClusterPipeline.move = block_pipeline_command(RedisCluster.move) +ClusterPipeline.mset = block_pipeline_command(RedisCluster.mset) +ClusterPipeline.msetnx = block_pipeline_command(RedisCluster.msetnx) +ClusterPipeline.pfmerge = block_pipeline_command(RedisCluster.pfmerge) +ClusterPipeline.pfcount = block_pipeline_command(RedisCluster.pfcount) +ClusterPipeline.ping = block_pipeline_command(RedisCluster.ping) +ClusterPipeline.publish = block_pipeline_command(RedisCluster.publish) +ClusterPipeline.randomkey = block_pipeline_command(RedisCluster.randomkey) +ClusterPipeline.rename = block_pipeline_command(RedisCluster.rename) +ClusterPipeline.renamenx = block_pipeline_command(RedisCluster.renamenx) +ClusterPipeline.rpoplpush = block_pipeline_command(RedisCluster.rpoplpush) +ClusterPipeline.scan = block_pipeline_command(RedisCluster.scan) +ClusterPipeline.sdiff = block_pipeline_command(RedisCluster.sdiff) +ClusterPipeline.sdiffstore = block_pipeline_command(RedisCluster.sdiffstore) +ClusterPipeline.sinter = block_pipeline_command(RedisCluster.sinter) +ClusterPipeline.sinterstore = block_pipeline_command(RedisCluster.sinterstore) +ClusterPipeline.smove = block_pipeline_command(RedisCluster.smove) +ClusterPipeline.sort = block_pipeline_command(RedisCluster.sort) +ClusterPipeline.sunion = block_pipeline_command(RedisCluster.sunion) +ClusterPipeline.sunionstore = block_pipeline_command(RedisCluster.sunionstore) +ClusterPipeline.readwrite = block_pipeline_command(RedisCluster.readwrite) +ClusterPipeline.readonly = block_pipeline_command(RedisCluster.readonly) + + +class PipelineCommand(object): + """ + """ + + def __init__(self, args, options=None, position=None): + self.args = args + if options is None: + options = {} + self.options = options + self.position = position + self.result = None + self.node = None + self.asking = False + + +class NodeCommands(object): + """ + """ + + def __init__(self, parse_response, connection_pool, connection): + """ + """ + self.parse_response = parse_response + self.connection_pool = connection_pool + self.connection = connection + self.commands = [] + + def append(self, c): + """ + """ + self.commands.append(c) + + def write(self): + """ + Code borrowed from Redis so it can be fixed + """ + connection = self.connection + commands = self.commands + + # We are going to clobber the commands with the write, so go ahead + # and ensure that nothing is sitting there from a previous run. + for c in commands: + c.result = None + + # build up all commands into a single request to increase network perf + # send all the commands and catch connection and timeout errors. + try: + connection.send_packed_command( + connection.pack_commands([c.args for c in commands])) + except (ConnectionError, TimeoutError) as e: + for c in commands: + c.result = e + + def read(self): + """ + """ + connection = self.connection + for c in self.commands: + + # if there is a result on this command, + # it means we ran into an exception + # like a connection error. Trying to parse + # a response on a connection that + # is no longer open will result in a + # connection error raised by redis-py. + # but redis-py doesn't check in parse_response + # that the sock object is + # still set and if you try to + # read from a closed connection, it will + # result in an AttributeError because + # it will do a readline() call on None. + # This can have all kinds of nasty side-effects. + # Treating this case as a connection error + # is fine because it will dump + # the connection object back into the + # pool and on the next write, it will + # explicitly open the connection and all will be well. + if c.result is None: + try: + c.result = self.parse_response( + connection, c.args[0], **c.options) + except (ConnectionError, TimeoutError) as e: + for c in self.commands: + c.result = e + return + except RedisError: + c.result = sys.exc_info()[1] diff --git a/redis/commands/__init__.py b/redis/commands/__init__.py index f1ddaaabc1..a4728d0ac4 100644 --- a/redis/commands/__init__.py +++ b/redis/commands/__init__.py @@ -1,11 +1,15 @@ +from .cluster import ClusterCommands from .core import CoreCommands -from .redismodules import RedisModuleCommands from .helpers import list_or_args +from .parser import CommandsParser +from .redismodules import RedisModuleCommands from .sentinel import SentinelCommands __all__ = [ + 'ClusterCommands', + 'CommandsParser', 'CoreCommands', + 'list_or_args', 'RedisModuleCommands', - 'SentinelCommands', - 'list_or_args' + 'SentinelCommands' ] diff --git a/redis/commands/cluster.py b/redis/commands/cluster.py new file mode 100644 index 0000000000..6c7740d5e9 --- /dev/null +++ b/redis/commands/cluster.py @@ -0,0 +1,926 @@ +from redis.exceptions import ( + ConnectionError, + DataError, + RedisError, +) +from redis.crc import key_slot +from .core import DataAccessCommands +from .helpers import list_or_args + + +class ClusterMultiKeyCommands: + """ + A class containing commands that handle more than one key + """ + + def _partition_keys_by_slot(self, keys): + """ + Split keys into a dictionary that maps a slot to + a list of keys. + """ + slots_to_keys = {} + for key in keys: + k = self.encoder.encode(key) + slot = key_slot(k) + slots_to_keys.setdefault(slot, []).append(key) + + return slots_to_keys + + def mget_nonatomic(self, keys, *args): + """ + Splits the keys into different slots and then calls MGET + for the keys of every slot. This operation will not be atomic + if keys belong to more than one slot. + + Returns a list of values ordered identically to ``keys`` + """ + + from redis.client import EMPTY_RESPONSE + options = {} + if not args: + options[EMPTY_RESPONSE] = [] + + # Concatenate all keys into a list + keys = list_or_args(keys, args) + # Split keys into slots + slots_to_keys = self._partition_keys_by_slot(keys) + + # Call MGET for every slot and concatenate + # the results + # We must make sure that the keys are returned in order + all_results = {} + for slot_keys in slots_to_keys.values(): + slot_values = self.execute_command( + 'MGET', *slot_keys, **options) + + slot_results = dict(zip(slot_keys, slot_values)) + all_results.update(slot_results) + + # Sort the results + vals_in_order = [all_results[key] for key in keys] + return vals_in_order + + def mset_nonatomic(self, mapping): + """ + Sets key/values based on a mapping. Mapping is a dictionary of + key/value pairs. Both keys and values should be strings or types that + can be cast to a string via str(). + + Splits the keys into different slots and then calls MSET + for the keys of every slot. This operation will not be atomic + if keys belong to more than one slot. + """ + + # Partition the keys by slot + slots_to_pairs = {} + for pair in mapping.items(): + # encode the key + k = self.encoder.encode(pair[0]) + slot = key_slot(k) + slots_to_pairs.setdefault(slot, []).extend(pair) + + # Call MSET for every slot and concatenate + # the results (one result per slot) + res = [] + for pairs in slots_to_pairs.values(): + res.append(self.execute_command('MSET', *pairs)) + + return res + + def _split_command_across_slots(self, command, *keys): + """ + Runs the given command once for the keys + of each slot. Returns the sum of the return values. + """ + # Partition the keys by slot + slots_to_keys = self._partition_keys_by_slot(keys) + + # Sum up the reply from each command + total = 0 + for slot_keys in slots_to_keys.values(): + total += self.execute_command(command, *slot_keys) + + return total + + def exists(self, *keys): + """ + Returns the number of ``names`` that exist in the + whole cluster. The keys are first split up into slots + and then an EXISTS command is sent for every slot + """ + return self._split_command_across_slots('EXISTS', *keys) + + def delete(self, *keys): + """ + Deletes the given keys in the cluster. + The keys are first split up into slots + and then an DEL command is sent for every slot + + Non-existant keys are ignored. + Returns the number of keys that were deleted. + """ + return self._split_command_across_slots('DEL', *keys) + + def touch(self, *keys): + """ + Updates the last access time of given keys across the + cluster. + + The keys are first split up into slots + and then an TOUCH command is sent for every slot + + Non-existant keys are ignored. + Returns the number of keys that were touched. + """ + return self._split_command_across_slots('TOUCH', *keys) + + def unlink(self, *keys): + """ + Remove the specified keys in a different thread. + + The keys are first split up into slots + and then an TOUCH command is sent for every slot + + Non-existant keys are ignored. + Returns the number of keys that were unlinked. + """ + return self._split_command_across_slots('UNLINK', *keys) + + +class ClusterManagementCommands: + """ + Redis Cluster management commands + + Commands with the 'target_nodes' argument can be executed on specified + nodes. By default, if target_nodes is not specified, the command will be + executed on the default cluster node. + + :param :target_nodes: type can be one of the followings: + - nodes flag: 'all', 'primaries', 'replicas', 'random' + - 'ClusterNode' + - 'list(ClusterNodes)' + - 'dict(any:clusterNodes)' + + for example: + primary = r.get_primaries()[0] + r.bgsave(target_nodes=primary) + r.bgsave(target_nodes='primaries') + """ + def bgsave(self, schedule=True, target_nodes=None): + """ + Tell the Redis server to save its data to disk. Unlike save(), + this method is asynchronous and returns immediately. + """ + pieces = [] + if schedule: + pieces.append("SCHEDULE") + return self.execute_command('BGSAVE', + *pieces, + target_nodes=target_nodes) + + def client_getname(self, target_nodes=None): + """ + Returns the current connection name from all nodes. + The result will be a dictionary with the IP and + connection name. + """ + return self.execute_command('CLIENT GETNAME', + target_nodes=target_nodes) + + def client_getredir(self, target_nodes=None): + """Returns the ID (an integer) of the client to whom we are + redirecting tracking notifications. + + see: https://redis.io/commands/client-getredir + """ + return self.execute_command('CLIENT GETREDIR', + target_nodes=target_nodes) + + def client_id(self, target_nodes=None): + """Returns the current connection id""" + return self.execute_command('CLIENT ID', + target_nodes=target_nodes) + + def client_info(self, target_nodes=None): + """ + Returns information and statistics about the current + client connection. + """ + return self.execute_command('CLIENT INFO', + target_nodes=target_nodes) + + def client_kill_filter(self, _id=None, _type=None, addr=None, + skipme=None, laddr=None, user=None, + target_nodes=None): + """ + Disconnects client(s) using a variety of filter options + :param id: Kills a client by its unique ID field + :param type: Kills a client by type where type is one of 'normal', + 'master', 'slave' or 'pubsub' + :param addr: Kills a client by its 'address:port' + :param skipme: If True, then the client calling the command + will not get killed even if it is identified by one of the filter + options. If skipme is not provided, the server defaults to skipme=True + :param laddr: Kills a client by its 'local (bind) address:port' + :param user: Kills a client for a specific user name + """ + args = [] + if _type is not None: + client_types = ('normal', 'master', 'slave', 'pubsub') + if str(_type).lower() not in client_types: + raise DataError("CLIENT KILL type must be one of %r" % ( + client_types,)) + args.extend((b'TYPE', _type)) + if skipme is not None: + if not isinstance(skipme, bool): + raise DataError("CLIENT KILL skipme must be a bool") + if skipme: + args.extend((b'SKIPME', b'YES')) + else: + args.extend((b'SKIPME', b'NO')) + if _id is not None: + args.extend((b'ID', _id)) + if addr is not None: + args.extend((b'ADDR', addr)) + if laddr is not None: + args.extend((b'LADDR', laddr)) + if user is not None: + args.extend((b'USER', user)) + if not args: + raise DataError("CLIENT KILL ... ... " + " must specify at least one filter") + return self.execute_command('CLIENT KILL', *args, + target_nodes=target_nodes) + + def client_kill(self, address, target_nodes=None): + "Disconnects the client at ``address`` (ip:port)" + return self.execute_command('CLIENT KILL', address, + target_nodes=target_nodes) + + def client_list(self, _type=None, target_nodes=None): + """ + Returns a list of currently connected clients to the entire cluster. + If type of client specified, only that type will be returned. + :param _type: optional. one of the client types (normal, master, + replica, pubsub) + """ + if _type is not None: + client_types = ('normal', 'master', 'replica', 'pubsub') + if str(_type).lower() not in client_types: + raise DataError("CLIENT LIST _type must be one of %r" % ( + client_types,)) + return self.execute_command('CLIENT LIST', + b'TYPE', + _type, + target_noes=target_nodes) + return self.execute_command('CLIENT LIST', + target_nodes=target_nodes) + + def client_pause(self, timeout, target_nodes=None): + """ + Suspend all the Redis clients for the specified amount of time + :param timeout: milliseconds to pause clients + """ + if not isinstance(timeout, int): + raise DataError("CLIENT PAUSE timeout must be an integer") + return self.execute_command('CLIENT PAUSE', str(timeout), + target_nodes=target_nodes) + + def client_reply(self, reply, target_nodes=None): + """Enable and disable redis server replies. + ``reply`` Must be ON OFF or SKIP, + ON - The default most with server replies to commands + OFF - Disable server responses to commands + SKIP - Skip the response of the immediately following command. + + Note: When setting OFF or SKIP replies, you will need a client object + with a timeout specified in seconds, and will need to catch the + TimeoutError. + The test_client_reply unit test illustrates this, and + conftest.py has a client with a timeout. + See https://redis.io/commands/client-reply + """ + replies = ['ON', 'OFF', 'SKIP'] + if reply not in replies: + raise DataError('CLIENT REPLY must be one of %r' % replies) + return self.execute_command("CLIENT REPLY", reply, + target_nodes=target_nodes) + + def client_setname(self, name, target_nodes=None): + "Sets the current connection name" + return self.execute_command('CLIENT SETNAME', name, + target_nodes=target_nodes) + + def client_trackinginfo(self, target_nodes=None): + """ + Returns the information about the current client connection's + use of the server assisted client side cache. + See https://redis.io/commands/client-trackinginfo + """ + return self.execute_command('CLIENT TRACKINGINFO', + target_nodes=target_nodes) + + def client_unblock(self, client_id, error=False, target_nodes=None): + """ + Unblocks a connection by its client id. + If ``error`` is True, unblocks the client with a special error message. + If ``error`` is False (default), the client is unblocked using the + regular timeout mechanism. + """ + args = ['CLIENT UNBLOCK', int(client_id)] + if error: + args.append(b'ERROR') + return self.execute_command(*args, target_nodes=target_nodes) + + def client_unpause(self, target_nodes=None): + """ + Unpause all redis clients + """ + return self.execute_command('CLIENT UNPAUSE', + target_nodes=target_nodes) + + def command(self, target_nodes=None): + """ + Returns dict reply of details about all Redis commands. + """ + return self.execute_command('COMMAND', target_nodes=target_nodes) + + def command_count(self, target_nodes=None): + """ + Returns Integer reply of number of total commands in this Redis server. + """ + return self.execute_command('COMMAND COUNT', target_nodes=target_nodes) + + def config_get(self, pattern="*", target_nodes=None): + """ + Return a dictionary of configuration based on the ``pattern`` + """ + return self.execute_command('CONFIG GET', + pattern, + target_nodes=target_nodes) + + def config_resetstat(self, target_nodes=None): + """Reset runtime statistics""" + return self.execute_command('CONFIG RESETSTAT', + target_nodes=target_nodes) + + def config_rewrite(self, target_nodes=None): + """ + Rewrite config file with the minimal change to reflect running config. + """ + return self.execute_command('CONFIG REWRITE', + target_nodes=target_nodes) + + def config_set(self, name, value, target_nodes=None): + "Set config item ``name`` with ``value``" + return self.execute_command('CONFIG SET', + name, + value, + target_nodes=target_nodes) + + def dbsize(self, target_nodes=None): + """ + Sums the number of keys in the target nodes' DB. + + :target_nodes: 'ClusterNode' or 'list(ClusterNodes)' + The node/s to execute the command on + """ + return self.execute_command('DBSIZE', + target_nodes=target_nodes) + + def debug_object(self, key): + raise NotImplementedError( + "DEBUG OBJECT is intentionally not implemented in the client." + ) + + def debug_segfault(self): + raise NotImplementedError( + "DEBUG SEGFAULT is intentionally not implemented in the client." + ) + + def echo(self, value, target_nodes): + """Echo the string back from the server""" + return self.execute_command('ECHO', value, + target_nodes=target_nodes) + + def flushall(self, asynchronous=False, target_nodes=None): + """ + Delete all keys in the database. + In cluster mode this method is the same as flushdb + + ``asynchronous`` indicates whether the operation is + executed asynchronously by the server. + """ + args = [] + if asynchronous: + args.append(b'ASYNC') + return self.execute_command('FLUSHALL', + *args, + target_nodes=target_nodes) + + def flushdb(self, asynchronous=False, target_nodes=None): + """ + Delete all keys in the database. + + ``asynchronous`` indicates whether the operation is + executed asynchronously by the server. + """ + args = [] + if asynchronous: + args.append(b'ASYNC') + return self.execute_command('FLUSHDB', + *args, + target_nodes=target_nodes) + + def info(self, section=None, target_nodes=None): + """ + Returns a dictionary containing information about the Redis server + + The ``section`` option can be used to select a specific section + of information + + The section option is not supported by older versions of Redis Server, + and will generate ResponseError + """ + if section is None: + return self.execute_command('INFO', + target_nodes=target_nodes) + else: + return self.execute_command('INFO', + section, + target_nodes=target_nodes) + + def keys(self, pattern='*', target_nodes=None): + "Returns a list of keys matching ``pattern``" + return self.execute_command('KEYS', pattern, target_nodes=target_nodes) + + def lastsave(self, target_nodes=None): + """ + Return a Python datetime object representing the last time the + Redis database was saved to disk + """ + return self.execute_command('LASTSAVE', + target_nodes=target_nodes) + + def memory_doctor(self): + raise NotImplementedError( + "MEMORY DOCTOR is intentionally not implemented in the client." + ) + + def memory_help(self): + raise NotImplementedError( + "MEMORY HELP is intentionally not implemented in the client." + ) + + def memory_malloc_stats(self, target_nodes=None): + """Return an internal statistics report from the memory allocator.""" + return self.execute_command('MEMORY MALLOC-STATS', + target_nodes=target_nodes) + + def memory_purge(self, target_nodes=None): + """Attempts to purge dirty pages for reclamation by allocator""" + return self.execute_command('MEMORY PURGE', + target_nodes=target_nodes) + + def memory_stats(self, target_nodes=None): + """Return a dictionary of memory stats""" + return self.execute_command('MEMORY STATS', + target_nodes=target_nodes) + + def memory_usage(self, key, samples=None): + """ + Return the total memory usage for key, its value and associated + administrative overheads. + + For nested data structures, ``samples`` is the number of elements to + sample. If left unspecified, the server's default is 5. Use 0 to sample + all elements. + """ + args = [] + if isinstance(samples, int): + args.extend([b'SAMPLES', samples]) + return self.execute_command('MEMORY USAGE', key, *args) + + def object(self, infotype, key): + """Return the encoding, idletime, or refcount about the key""" + return self.execute_command('OBJECT', infotype, key, infotype=infotype) + + def ping(self, target_nodes=None): + """ + Ping the cluster's servers. + If no target nodes are specified, sent to all nodes and returns True if + the ping was successful across all nodes. + """ + return self.execute_command('PING', + target_nodes=target_nodes) + + def randomkey(self, target_nodes=None): + """ + Returns the name of a random key" + """ + return self.execute_command('RANDOMKEY', target_nodes=target_nodes) + + def save(self, target_nodes=None): + """ + Tell the Redis server to save its data to disk, + blocking until the save is complete + """ + return self.execute_command('SAVE', target_nodes=target_nodes) + + def scan(self, cursor=0, match=None, count=None, _type=None, + target_nodes=None): + """ + Incrementally return lists of key names. Also return a cursor + indicating the scan position. + + ``match`` allows for filtering the keys by pattern + + ``count`` provides a hint to Redis about the number of keys to + return per batch. + + ``_type`` filters the returned values by a particular Redis type. + Stock Redis instances allow for the following types: + HASH, LIST, SET, STREAM, STRING, ZSET + Additionally, Redis modules can expose other types as well. + """ + pieces = [cursor] + if match is not None: + pieces.extend([b'MATCH', match]) + if count is not None: + pieces.extend([b'COUNT', count]) + if _type is not None: + pieces.extend([b'TYPE', _type]) + return self.execute_command('SCAN', *pieces, target_nodes=target_nodes) + + def scan_iter(self, match=None, count=None, _type=None, target_nodes=None): + """ + Make an iterator using the SCAN command so that the client doesn't + need to remember the cursor position. + + ``match`` allows for filtering the keys by pattern + + ``count`` provides a hint to Redis about the number of keys to + return per batch. + + ``_type`` filters the returned values by a particular Redis type. + Stock Redis instances allow for the following types: + HASH, LIST, SET, STREAM, STRING, ZSET + Additionally, Redis modules can expose other types as well. + """ + cursor = '0' + while cursor != 0: + cursor, data = self.scan(cursor=cursor, match=match, + count=count, _type=_type, + target_nodes=target_nodes) + yield from data + + def shutdown(self, save=False, nosave=False, target_nodes=None): + """Shutdown the Redis server. If Redis has persistence configured, + data will be flushed before shutdown. If the "save" option is set, + a data flush will be attempted even if there is no persistence + configured. If the "nosave" option is set, no data flush will be + attempted. The "save" and "nosave" options cannot both be set. + """ + if save and nosave: + raise DataError('SHUTDOWN save and nosave cannot both be set') + args = ['SHUTDOWN'] + if save: + args.append('SAVE') + if nosave: + args.append('NOSAVE') + try: + self.execute_command(*args, target_nodes=target_nodes) + except ConnectionError: + # a ConnectionError here is expected + return + raise RedisError("SHUTDOWN seems to have failed.") + + def slowlog_get(self, num=None, target_nodes=None): + """ + Get the entries from the slowlog. If ``num`` is specified, get the + most recent ``num`` items. + """ + args = ['SLOWLOG GET'] + if num is not None: + args.append(num) + + return self.execute_command(*args, + target_nodes=target_nodes) + + def slowlog_len(self, target_nodes=None): + "Get the number of items in the slowlog" + return self.execute_command('SLOWLOG LEN', + target_nodes=target_nodes) + + def slowlog_reset(self, target_nodes=None): + "Remove all items in the slowlog" + return self.execute_command('SLOWLOG RESET', + target_nodes=target_nodes) + + def stralgo(self, algo, value1, value2, specific_argument='strings', + len=False, idx=False, minmatchlen=None, withmatchlen=False, + target_nodes=None): + """ + Implements complex algorithms that operate on strings. + Right now the only algorithm implemented is the LCS algorithm + (longest common substring). However new algorithms could be + implemented in the future. + + ``algo`` Right now must be LCS + ``value1`` and ``value2`` Can be two strings or two keys + ``specific_argument`` Specifying if the arguments to the algorithm + will be keys or strings. strings is the default. + ``len`` Returns just the len of the match. + ``idx`` Returns the match positions in each string. + ``minmatchlen`` Restrict the list of matches to the ones of a given + minimal length. Can be provided only when ``idx`` set to True. + ``withmatchlen`` Returns the matches with the len of the match. + Can be provided only when ``idx`` set to True. + """ + # check validity + supported_algo = ['LCS'] + if algo not in supported_algo: + raise DataError("The supported algorithms are: %s" + % (', '.join(supported_algo))) + if specific_argument not in ['keys', 'strings']: + raise DataError("specific_argument can be only" + " keys or strings") + if len and idx: + raise DataError("len and idx cannot be provided together.") + + pieces = [algo, specific_argument.upper(), value1, value2] + if len: + pieces.append(b'LEN') + if idx: + pieces.append(b'IDX') + try: + int(minmatchlen) + pieces.extend([b'MINMATCHLEN', minmatchlen]) + except TypeError: + pass + if withmatchlen: + pieces.append(b'WITHMATCHLEN') + if specific_argument == 'strings' and target_nodes is None: + target_nodes = 'default-node' + return self.execute_command('STRALGO', *pieces, len=len, idx=idx, + minmatchlen=minmatchlen, + withmatchlen=withmatchlen, + target_nodes=target_nodes) + + def time(self, target_nodes=None): + """ + Returns the server time as a 2-item tuple of ints: + (seconds since epoch, microseconds into this second). + """ + return self.execute_command('TIME', target_nodes=target_nodes) + + def wait(self, num_replicas, timeout, target_nodes=None): + """ + Redis synchronous replication + That returns the number of replicas that processed the query when + we finally have at least ``num_replicas``, or when the ``timeout`` was + reached. + + If more than one target node are passed the result will be summed up + """ + return self.execute_command('WAIT', num_replicas, + timeout, + target_nodes=target_nodes) + + +class ClusterPubSubCommands: + """ + Redis PubSub commands for RedisCluster use. + see https://redis.io/topics/pubsub + """ + def publish(self, channel, message, target_nodes=None): + """ + Publish ``message`` on ``channel``. + Returns the number of subscribers the message was delivered to. + """ + return self.execute_command('PUBLISH', channel, message, + target_nodes=target_nodes) + + def pubsub_channels(self, pattern='*', target_nodes=None): + """ + Return a list of channels that have at least one subscriber + """ + return self.execute_command('PUBSUB CHANNELS', pattern, + target_nodes=target_nodes) + + def pubsub_numpat(self, target_nodes=None): + """ + Returns the number of subscriptions to patterns + """ + return self.execute_command('PUBSUB NUMPAT', target_nodes=target_nodes) + + def pubsub_numsub(self, *args, target_nodes=None): + """ + Return a list of (channel, number of subscribers) tuples + for each channel given in ``*args`` + """ + return self.execute_command('PUBSUB NUMSUB', *args, + target_nodes=target_nodes) + + +class ClusterCommands(ClusterManagementCommands, ClusterMultiKeyCommands, + ClusterPubSubCommands, DataAccessCommands): + """ + Redis Cluster commands + + Commands with the 'target_nodes' argument can be executed on specified + nodes. By default, if target_nodes is not specified, the command will be + executed on the default cluster node. + + :param :target_nodes: type can be one of the followings: + - nodes flag: 'all', 'primaries', 'replicas', 'random' + - 'ClusterNode' + - 'list(ClusterNodes)' + - 'dict(any:clusterNodes)' + + for example: + r.cluster_info(target_nodes='all') + """ + def cluster_addslots(self, target_node, *slots): + """ + Assign new hash slots to receiving node. Sends to specified node. + + :target_node: 'ClusterNode' + The node to execute the command on + """ + return self.execute_command('CLUSTER ADDSLOTS', *slots, + target_nodes=target_node) + + def cluster_countkeysinslot(self, slot_id): + """ + Return the number of local keys in the specified hash slot + Send to node based on specified slot_id + """ + return self.execute_command('CLUSTER COUNTKEYSINSLOT', slot_id) + + def cluster_count_failure_report(self, node_id): + """ + Return the number of failure reports active for a given node + Sends to a random node + """ + return self.execute_command('CLUSTER COUNT-FAILURE-REPORTS', node_id) + + def cluster_delslots(self, *slots): + """ + Set hash slots as unbound in the cluster. + It determines by it self what node the slot is in and sends it there + + Returns a list of the results for each processed slot. + """ + return [ + self.execute_command('CLUSTER DELSLOTS', slot) + for slot in slots + ] + + def cluster_failover(self, target_node, option=None): + """ + Forces a slave to perform a manual failover of its master + Sends to specified node + + :target_node: 'ClusterNode' + The node to execute the command on + """ + if option: + if option.upper() not in ['FORCE', 'TAKEOVER']: + raise RedisError( + 'Invalid option for CLUSTER FAILOVER command: {0}'.format( + option)) + else: + return self.execute_command('CLUSTER FAILOVER', option, + target_nodes=target_node) + else: + return self.execute_command('CLUSTER FAILOVER', + target_nodes=target_node) + + def cluster_info(self, target_nodes=None): + """ + Provides info about Redis Cluster node state. + The command will be sent to a random node in the cluster if no target + node is specified. + """ + return self.execute_command('CLUSTER INFO', target_nodes=target_nodes) + + def cluster_keyslot(self, key): + """ + Returns the hash slot of the specified key + Sends to random node in the cluster + """ + return self.execute_command('CLUSTER KEYSLOT', key) + + def cluster_meet(self, host, port, target_nodes=None): + """ + Force a node cluster to handshake with another node. + Sends to specified node. + """ + return self.execute_command('CLUSTER MEET', host, port, + target_nodes=target_nodes) + + def cluster_nodes(self): + """ + Force a node cluster to handshake with another node + + Sends to random node in the cluster + """ + return self.execute_command('CLUSTER NODES') + + def cluster_replicate(self, target_nodes, node_id): + """ + Reconfigure a node as a slave of the specified master node + """ + return self.execute_command('CLUSTER REPLICATE', node_id, + target_nodes=target_nodes) + + def cluster_reset(self, soft=True, target_nodes=None): + """ + Reset a Redis Cluster node + + If 'soft' is True then it will send 'SOFT' argument + If 'soft' is False then it will send 'HARD' argument + """ + return self.execute_command('CLUSTER RESET', + b'SOFT' if soft else b'HARD', + target_nodes=target_nodes) + + def cluster_save_config(self, target_nodes=None): + """ + Forces the node to save cluster state on disk + """ + return self.execute_command('CLUSTER SAVECONFIG', + target_nodes=target_nodes) + + def cluster_get_keys_in_slot(self, slot, num_keys): + """ + Returns the number of keys in the specified cluster slot + """ + return self.execute_command('CLUSTER GETKEYSINSLOT', slot, num_keys) + + def cluster_set_config_epoch(self, epoch, target_nodes=None): + """ + Set the configuration epoch in a new node + """ + return self.execute_command('CLUSTER SET-CONFIG-EPOCH', epoch, + target_nodes=target_nodes) + + def cluster_setslot(self, target_node, node_id, slot_id, state): + """ + Bind an hash slot to a specific node + + :target_node: 'ClusterNode' + The node to execute the command on + """ + if state.upper() in ('IMPORTING', 'NODE', 'MIGRATING'): + return self.execute_command('CLUSTER SETSLOT', slot_id, state, + node_id, target_nodes=target_node) + elif state.upper() == 'STABLE': + raise RedisError('For "stable" state please use ' + 'cluster_setslot_stable') + else: + raise RedisError('Invalid slot state: {0}'.format(state)) + + def cluster_setslot_stable(self, slot_id): + """ + Clears migrating / importing state from the slot. + It determines by it self what node the slot is in and sends it there. + """ + return self.execute_command('CLUSTER SETSLOT', slot_id, 'STABLE') + + def cluster_replicas(self, node_id, target_nodes=None): + """ + Provides a list of replica nodes replicating from the specified primary + target node. + """ + return self.execute_command('CLUSTER REPLICAS', node_id, + target_nodes=target_nodes) + + def cluster_slots(self, target_nodes=None): + """ + Get array of Cluster slot to node mappings + """ + return self.execute_command('CLUSTER SLOTS', target_nodes=target_nodes) + + def readonly(self, target_nodes=None): + """ + Enables read queries. + The command will be sent to the default cluster node if target_nodes is + not specified. + """ + if target_nodes == 'replicas' or target_nodes == 'all': + # read_from_replicas will only be enabled if the READONLY command + # is sent to all replicas + self.read_from_replicas = True + return self.execute_command('READONLY', target_nodes=target_nodes) + + def readwrite(self, target_nodes=None): + """ + Disables read queries. + The command will be sent to the default cluster node if target_nodes is + not specified. + """ + # Reset read from replicas flag + self.read_from_replicas = False + return self.execute_command('READWRITE', target_nodes=target_nodes) diff --git a/redis/commands/core.py b/redis/commands/core.py index 5fad0b65f1..ad45adcfad 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -12,15 +12,11 @@ ) -class CoreCommands: +class ACLCommands: """ - A class containing all of the implemented redis commands. This class is - to be used as a mixin. + Redis Access Control List (ACL) commands. + see: https://redis.io/topics/acl """ - - # SERVER INFORMATION - - # ACL methods def acl_cat(self, category=None): """ Returns a list of categories or commands within a category. @@ -297,6 +293,11 @@ def acl_whoami(self): """ return self.execute_command('ACL WHOAMI') + +class ManagementCommands: + """ + Redis management commands + """ def bgrewriteaof(self): """Tell the Redis server to rewrite the AOF file from data in memory. @@ -494,6 +495,14 @@ def client_unpause(self): """ return self.execute_command('CLIENT UNPAUSE') + def command_info(self): + raise NotImplementedError( + "COMMAND INFO is intentionally not implemented in the client." + ) + + def command_count(self): + return self.execute_command('COMMAND COUNT') + def readwrite(self): """ Disables read queries for a connection to a Redis Cluster slave node. @@ -541,6 +550,9 @@ def config_rewrite(self): """ return self.execute_command('CONFIG REWRITE') + def cluster(self, cluster_arg, *args): + return self.execute_command('CLUSTER %s' % cluster_arg.upper(), *args) + def dbsize(self): """ Returns the number of keys in the current database @@ -764,6 +776,17 @@ def quit(self): """ return self.execute_command('QUIT') + def replicaof(self, *args): + """ + Update the replication settings of a redis replica, on the fly. + Examples of valid arguments include: + NO ONE (set no replication) + host port (set to the host and port of a redis server) + + For more information check https://redis.io/commands/replicaof + """ + return self.execute_command('REPLICAOF', *args) + def save(self): """ Tell the Redis server to save its data to disk, @@ -858,7 +881,11 @@ def wait(self, num_replicas, timeout): """ return self.execute_command('WAIT', num_replicas, timeout) - # BASIC KEY COMMANDS + +class BasicKeyCommands: + """ + Redis basic key-based commands + """ def append(self, key, value): """ Appends the string ``value`` to the value at ``key``. If ``key`` @@ -1614,7 +1641,12 @@ def unlink(self, *names): """ return self.execute_command('UNLINK', *names) - # LIST COMMANDS + +class ListCommands: + """ + Redis commands for List data type. + see: https://redis.io/topics/data-types#lists + """ def blpop(self, keys, timeout=0): """ LPOP a value off of the first non-empty list @@ -1915,7 +1947,12 @@ def sort(self, name, start=None, num=None, by=None, get=None, options = {'groups': len(get) if groups else None} return self.execute_command('SORT', *pieces, **options) - # SCAN COMMANDS + +class ScanCommands: + """ + Redis SCAN commands. + see: https://redis.io/commands/scan + """ def scan(self, cursor=0, match=None, count=None, _type=None): """ Incrementally return lists of key names. Also return a cursor @@ -2070,7 +2107,12 @@ def zscan_iter(self, name, match=None, count=None, score_cast_func=score_cast_func) yield from data - # SET COMMANDS + +class SetCommands: + """ + Redis commands for Set data type. + see: https://redis.io/topics/data-types#sets + """ def sadd(self, name, *values): """ Add ``value(s)`` to set ``name`` @@ -2208,7 +2250,12 @@ def sunionstore(self, dest, keys, *args): args = list_or_args(keys, args) return self.execute_command('SUNIONSTORE', dest, *args) - # STREAMS COMMANDS + +class StreamCommands: + """ + Redis commands for Stream data type. + see: https://redis.io/topics/streams-intro + """ def xack(self, name, groupname, *ids): """ Acknowledges the successful processing of one or more messages. @@ -2685,7 +2732,12 @@ def xtrim(self, name, maxlen=None, approximate=True, minid=None, return self.execute_command('XTRIM', name, *pieces) - # SORTED SET COMMANDS + +class SortedSetCommands: + """ + Redis commands for Sorted Sets data type. + see: https://redis.io/topics/data-types-intro#redis-sorted-sets + """ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, gt=None, lt=None): """ @@ -3273,7 +3325,12 @@ def _zaggregate(self, command, dest, keys, aggregate=None, pieces.append(b'WITHSCORES') return self.execute_command(*pieces, **options) - # HYPERLOGLOG COMMANDS + +class HyperlogCommands: + """ + Redis commands of HyperLogLogs data type. + see: https://redis.io/topics/data-types-intro#hyperloglogs + """ def pfadd(self, name, *values): """ Adds the specified elements to the specified HyperLogLog. @@ -3299,7 +3356,12 @@ def pfmerge(self, dest, *sources): """ return self.execute_command('PFMERGE', dest, *sources) - # HASH COMMANDS + +class HashCommands: + """ + Redis commands for Hash data type. + see: https://redis.io/topics/data-types-intro#redis-hashes + """ def hdel(self, name, *keys): """ Delete ``keys`` from hash ``name`` @@ -3439,6 +3501,12 @@ def hstrlen(self, name, key): """ return self.execute_command('HSTRLEN', name, key) + +class PubSubCommands: + """ + Redis PubSub commands. + see https://redis.io/topics/pubsub + """ def publish(self, channel, message): """ Publish ``message`` on ``channel``. @@ -3473,20 +3541,12 @@ def pubsub_numsub(self, *args): """ return self.execute_command('PUBSUB NUMSUB', *args) - def cluster(self, cluster_arg, *args): - return self.execute_command('CLUSTER %s' % cluster_arg.upper(), *args) - - def replicaof(self, *args): - """ - Update the replication settings of a redis replica, on the fly. - Examples of valid arguments include: - NO ONE (set no replication) - host port (set to the host and port of a redis server) - - For more information check https://redis.io/commands/replicaof - """ - return self.execute_command('REPLICAOF', *args) +class ScriptCommands: + """ + Redis Lua script commands. see: + https://redis.com/ebook/part-3-next-steps/chapter-11-scripting-redis-with-lua/ + """ def eval(self, script, numkeys, *keys_and_args): """ Execute the Lua ``script``, specifying the ``numkeys`` the script @@ -3572,7 +3632,12 @@ def register_script(self, script): """ return Script(self, script) - # GEO COMMANDS + +class GeoCommands: + """ + Redis Geospatial commands. + see: https://redis.com/redis-best-practices/indexing-patterns/geospatial/ + """ def geoadd(self, name, values, nx=False, xx=False, ch=False): """ Add the specified geospatial items to the specified key identified @@ -3882,7 +3947,12 @@ def _geosearchgeneric(self, command, *args, **kwargs): return self.execute_command(command, *pieces, **kwargs) - # MODULE COMMANDS + +class ModuleCommands: + """ + Redis Module commands. + see: https://redis.io/topics/modules-intro + """ def module_load(self, path, *args): """ Loads the module from ``path``. @@ -3924,7 +3994,9 @@ def command(self): class Script: - "An executable Lua script object returned by ``register_script``" + """ + An executable Lua script object returned by ``register_script`` + """ def __init__(self, registered_client, script): self.registered_client = registered_client @@ -4053,3 +4125,22 @@ def execute(self): command = self.command self.reset() return self.client.execute_command(*command) + + +class DataAccessCommands(BasicKeyCommands, ListCommands, + ScanCommands, SetCommands, StreamCommands, + SortedSetCommands, + HyperlogCommands, HashCommands, GeoCommands, + ): + """ + A class containing all of the implemented data access redis commands. + This class is to be used as a mixin. + """ + + +class CoreCommands(ACLCommands, DataAccessCommands, ManagementCommands, + ModuleCommands, PubSubCommands, ScriptCommands): + """ + A class containing all of the implemented redis commands. This class is + to be used as a mixin. + """ diff --git a/redis/commands/parser.py b/redis/commands/parser.py new file mode 100644 index 0000000000..d8b03271db --- /dev/null +++ b/redis/commands/parser.py @@ -0,0 +1,118 @@ +from redis.exceptions import ( + RedisError, + ResponseError +) +from redis.utils import str_if_bytes + + +class CommandsParser: + """ + Parses Redis commands to get command keys. + COMMAND output is used to determine key locations. + Commands that do not have a predefined key location are flagged with + 'movablekeys', and these commands' keys are determined by the command + 'COMMAND GETKEYS'. + """ + def __init__(self, redis_connection): + self.initialized = False + self.commands = {} + self.initialize(redis_connection) + + def initialize(self, r): + self.commands = r.execute_command("COMMAND") + + # As soon as this PR is merged into Redis, we should reimplement + # our logic to use COMMAND INFO changes to determine the key positions + # https://github.com/redis/redis/pull/8324 + def get_keys(self, redis_conn, *args): + """ + Get the keys from the passed command + """ + if len(args) < 2: + # The command has no keys in it + return None + + cmd_name = args[0].lower() + if cmd_name not in self.commands: + # try to split the command name and to take only the main command, + # e.g. 'memory' for 'memory usage' + cmd_name_split = cmd_name.split() + cmd_name = cmd_name_split[0] + if cmd_name in self.commands: + # save the splitted command to args + args = cmd_name_split + list(args[1:]) + else: + # We'll try to reinitialize the commands cache, if the engine + # version has changed, the commands may not be current + self.initialize(redis_conn) + if cmd_name not in self.commands: + raise RedisError("{0} command doesn't exist in Redis " + "commands".format(cmd_name.upper())) + + command = self.commands.get(cmd_name) + if 'movablekeys' in command['flags']: + keys = self._get_moveable_keys(redis_conn, *args) + elif 'pubsub' in command['flags']: + keys = self._get_pubsub_keys(*args) + else: + if command['step_count'] == 0 and command['first_key_pos'] == 0 \ + and command['last_key_pos'] == 0: + # The command doesn't have keys in it + return None + last_key_pos = command['last_key_pos'] + if last_key_pos < 0: + last_key_pos = len(args) - abs(last_key_pos) + keys_pos = list(range(command['first_key_pos'], last_key_pos + 1, + command['step_count'])) + keys = [args[pos] for pos in keys_pos] + + return keys + + def _get_moveable_keys(self, redis_conn, *args): + pieces = [] + cmd_name = args[0] + # The command name should be splitted into separate arguments, + # e.g. 'MEMORY USAGE' will be splitted into ['MEMORY', 'USAGE'] + pieces = pieces + cmd_name.split() + pieces = pieces + list(args[1:]) + try: + keys = redis_conn.execute_command('COMMAND GETKEYS', *pieces) + except ResponseError as e: + message = e.__str__() + if 'Invalid arguments' in message or \ + 'The command has no key arguments' in message: + return None + else: + raise e + return keys + + def _get_pubsub_keys(self, *args): + """ + Get the keys from pubsub command. + Although PubSub commands have predetermined key locations, they are not + supported in the 'COMMAND's output, so the key positions are hardcoded + in this method + """ + if len(args) < 2: + # The command has no keys in it + return None + args = [str_if_bytes(arg) for arg in args] + command = args[0].upper() + if command == 'PUBSUB': + # the second argument is a part of the command name, e.g. + # ['PUBSUB', 'NUMSUB', 'foo']. + pubsub_type = args[1].upper() + if pubsub_type in ['CHANNELS', 'NUMSUB']: + keys = args[2:] + elif command in ['SUBSCRIBE', 'PSUBSCRIBE', 'UNSUBSCRIBE', + 'PUNSUBSCRIBE']: + # format example: + # SUBSCRIBE channel [channel ...] + keys = list(args[1:]) + elif command == 'PUBLISH': + # format example: + # PUBLISH channel message + keys = [args[1]] + else: + keys = None + return keys diff --git a/redis/connection.py b/redis/connection.py index e01742d70e..eac9db358f 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -11,6 +11,7 @@ import threading import weakref +from redis.backoff import NoBackoff from redis.exceptions import ( AuthenticationError, AuthenticationWrongNumberOfArgsError, @@ -28,9 +29,9 @@ TimeoutError, ModuleError, ) -from redis.utils import HIREDIS_AVAILABLE, str_if_bytes -from redis.backoff import NoBackoff + from redis.retry import Retry +from redis.utils import HIREDIS_AVAILABLE, str_if_bytes try: import ssl @@ -498,7 +499,7 @@ def __init__(self, host='localhost', port=6379, db=0, password=None, encoding_errors='strict', decode_responses=False, parser_class=DefaultParser, socket_read_size=65536, health_check_interval=0, client_name=None, username=None, - retry=None): + retry=None, redis_connect_func=None): """ Initialize a new Connection. To specify a retry policy, first set `retry_on_timeout` to `True` @@ -528,8 +529,10 @@ def __init__(self, host='localhost', port=6379, db=0, password=None, self.health_check_interval = health_check_interval self.next_health_check = 0 self.encoder = Encoder(encoding, encoding_errors, decode_responses) + self.redis_connect_func = redis_connect_func self._sock = None - self._parser = parser_class(socket_read_size=socket_read_size) + self._socket_read_size = socket_read_size + self.set_parser(parser_class) self._connect_callbacks = [] self._buffer_cutoff = 6000 @@ -559,6 +562,14 @@ def register_connect_callback(self, callback): def clear_connect_callbacks(self): self._connect_callbacks = [] + def set_parser(self, parser_class): + """ + Creates a new instance of parser_class with socket size: + _socket_read_size and assigns it to the parser for the connection + :param parser_class: The required parser class + """ + self._parser = parser_class(socket_read_size=self._socket_read_size) + def connect(self): "Connects to the Redis server if not already connected" if self._sock: @@ -572,7 +583,12 @@ def connect(self): self._sock = sock try: - self.on_connect() + if self.redis_connect_func is None: + # Use the default on_connect function + self.on_connect() + else: + # Use the passed function redis_connect_func + self.redis_connect_func(self) except RedisError: # clean up after any error in on_connect self.disconnect() @@ -903,7 +919,8 @@ def __init__(self, path='', db=0, username=None, password=None, self.next_health_check = 0 self.encoder = Encoder(encoding, encoding_errors, decode_responses) self._sock = None - self._parser = parser_class(socket_read_size=socket_read_size) + self._socket_read_size = socket_read_size + self.set_parser(parser_class) self._connect_callbacks = [] self._buffer_cutoff = 6000 diff --git a/redis/crc.py b/redis/crc.py new file mode 100644 index 0000000000..7d2ee507be --- /dev/null +++ b/redis/crc.py @@ -0,0 +1,24 @@ +from binascii import crc_hqx + +# Redis Cluster's key space is divided into 16384 slots. +# For more information see: https://github.com/redis/redis/issues/2576 +REDIS_CLUSTER_HASH_SLOTS = 16384 + +__all__ = [ + "key_slot", + "REDIS_CLUSTER_HASH_SLOTS" +] + + +def key_slot(key, bucket=REDIS_CLUSTER_HASH_SLOTS): + """Calculate key slot for a given key. + See Keys distribution model in https://redis.io/topics/cluster-spec + :param key - bytes + :param bucket - int + """ + start = key.find(b"{") + if start > -1: + end = key.find(b"}", start + 1) + if end > -1 and end != start + 1: + key = key[start + 1: end] + return crc_hqx(key, 0) % bucket diff --git a/redis/exceptions.py b/redis/exceptions.py index 91eb3c7257..eb6ecc2dc5 100644 --- a/redis/exceptions.py +++ b/redis/exceptions.py @@ -84,3 +84,105 @@ class AuthenticationWrongNumberOfArgsError(ResponseError): were sent to the AUTH command """ pass + + +class RedisClusterException(Exception): + """ + Base exception for the RedisCluster client + """ + pass + + +class ClusterError(RedisError): + """ + Cluster errors occurred multiple times, resulting in an exhaustion of the + command execution TTL + """ + pass + + +class ClusterDownError(ClusterError, ResponseError): + """ + Error indicated CLUSTERDOWN error received from cluster. + By default Redis Cluster nodes stop accepting queries if they detect there + is at least a hash slot uncovered (no available node is serving it). + This way if the cluster is partially down (for example a range of hash + slots are no longer covered) the entire cluster eventually becomes + unavailable. It automatically returns available as soon as all the slots + are covered again. + """ + def __init__(self, resp): + self.args = (resp,) + self.message = resp + + +class AskError(ResponseError): + """ + Error indicated ASK error received from cluster. + When a slot is set as MIGRATING, the node will accept all queries that + pertain to this hash slot, but only if the key in question exists, + otherwise the query is forwarded using a -ASK redirection to the node that + is target of the migration. + src node: MIGRATING to dst node + get > ASK error + ask dst node > ASKING command + dst node: IMPORTING from src node + asking command only affects next command + any op will be allowed after asking command + """ + + def __init__(self, resp): + """should only redirect to master node""" + self.args = (resp,) + self.message = resp + slot_id, new_node = resp.split(' ') + host, port = new_node.rsplit(':', 1) + self.slot_id = int(slot_id) + self.node_addr = self.host, self.port = host, int(port) + + +class TryAgainError(ResponseError): + """ + Error indicated TRYAGAIN error received from cluster. + Operations on keys that don't exist or are - during resharding - split + between the source and destination nodes, will generate a -TRYAGAIN error. + """ + def __init__(self, *args, **kwargs): + pass + + +class ClusterCrossSlotError(ResponseError): + """ + Error indicated CROSSSLOT error received from cluster. + A CROSSSLOT error is generated when keys in a request don't hash to the + same slot. + """ + message = "Keys in request don't hash to the same slot" + + +class MovedError(AskError): + """ + Error indicated MOVED error received from cluster. + A request sent to a node that doesn't serve this key will be replayed with + a MOVED error that points to the correct node. + """ + pass + + +class MasterDownError(ClusterDownError): + """ + Error indicated MASTERDOWN error received from cluster. + Link with MASTER is down and replica-serve-stale-data is set to 'no'. + """ + pass + + +class SlotNotCoveredError(RedisClusterException): + """ + This error only happens in the case where the connection pool will try to + fetch what node that is covered by a given slot. + + If this error is raised the client should drop the current node layout and + attempt to reconnect and refresh the node layout again + """ + pass diff --git a/redis/utils.py b/redis/utils.py index 26fb002b89..0e78cc5f3b 100644 --- a/redis/utils.py +++ b/redis/utils.py @@ -36,3 +36,39 @@ def str_if_bytes(value): def safe_str(value): return str(str_if_bytes(value)) + + +def dict_merge(*dicts): + """ + Merge all provided dicts into 1 dict. + *dicts : `dict` + dictionaries to merge + """ + merged = {} + + for d in dicts: + merged.update(d) + + return merged + + +def list_keys_to_dict(key_list, callback): + return dict.fromkeys(key_list, callback) + + +def merge_result(command, res): + """ + Merge all items in `res` into a list. + + This command is used when sending a command to multiple nodes + and they result from each node should be merged into a single list. + + res : 'dict' + """ + result = set() + + for v in res.values(): + for value in v: + result.add(value) + + return list(result) diff --git a/tasks.py b/tasks.py index 306291c97f..138ca69409 100644 --- a/tasks.py +++ b/tasks.py @@ -40,7 +40,24 @@ def tests(c): """Run the redis-py test suite against the current python, with and without hiredis. """ - run("tox -e plain -e hiredis") + print("Starting Redis tests") + run("tox -e '{redis,cluster}'-'{plain,hiredis}'") + + +@task +def redis_tests(c): + """Run all Redis tests against the current python, + with and without hiredis.""" + print("Starting Redis tests") + run("tox -e redis-'{hiredis}'") + + +@task +def cluster_tests(c): + """Run all Redis Cluster tests against the current python, + with and without hiredis.""" + print("Starting RedisCluster tests") + run("tox -e cluster-'{plain,hiredis}'") @task diff --git a/tests/conftest.py b/tests/conftest.py index 9504333354..f29ebdebb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,10 @@ import pytest import random import redis +import time from distutils.version import LooseVersion from redis.connection import parse_url +from redis.exceptions import RedisClusterException from unittest.mock import Mock from urllib.parse import urlparse @@ -13,6 +15,7 @@ default_redis_url = "redis://localhost:6379/9" default_redismod_url = "redis://localhost:36379" +default_cluster_nodes = 6 def pytest_addoption(parser): @@ -27,11 +30,17 @@ def pytest_addoption(parser): " with loaded modules," " defaults to `%(default)s`") + parser.addoption('--redis-cluster-nodes', default=default_cluster_nodes, + action="store", + help="The number of cluster nodes that need to be " + "available before the test can start," + " defaults to `%(default)s`") + def _get_info(redis_url): client = redis.Redis.from_url(redis_url) info = client.info() - cmds = [c[0].upper().decode() for c in client.command()] + cmds = [command.upper() for command in client.command().keys()] if 'dping' in cmds: info["enterprise"] = True else: @@ -45,8 +54,10 @@ def pytest_sessionstart(session): info = _get_info(redis_url) version = info["redis_version"] arch_bits = info["arch_bits"] + cluster_enabled = info["cluster_enabled"] REDIS_INFO["version"] = version REDIS_INFO["arch_bits"] = arch_bits + REDIS_INFO["cluster_enabled"] = cluster_enabled REDIS_INFO["enterprise"] = info["enterprise"] # module info, if the second redis is running @@ -59,6 +70,42 @@ def pytest_sessionstart(session): except KeyError: pass + if cluster_enabled: + cluster_nodes = session.config.getoption("--redis-cluster-nodes") + wait_for_cluster_creation(redis_url, cluster_nodes) + + +def wait_for_cluster_creation(redis_url, cluster_nodes, timeout=20): + """ + Waits for the cluster creation to complete. + As soon as all :cluster_nodes: nodes become available, the cluster will be + considered ready. + :param redis_url: the cluster's url, e.g. redis://localhost:16379/0 + :param cluster_nodes: The number of nodes in the cluster + :param timeout: the amount of time to wait (in seconds) + """ + now = time.time() + end_time = now + timeout + client = None + print("Waiting for {0} cluster nodes to become available". + format(cluster_nodes)) + while now < end_time: + try: + client = redis.RedisCluster.from_url(redis_url) + if len(client.get_nodes()) == cluster_nodes: + print("All nodes are available!") + break + except RedisClusterException: + pass + time.sleep(1) + now = time.time() + if now >= end_time: + available_nodes = 0 if client is None else len(client.get_nodes()) + raise RedisClusterException( + "The cluster did not become available after {0} seconds. " + "Only {1} nodes out of {2} are available".format( + timeout, available_nodes, cluster_nodes)) + def skip_if_server_version_lt(min_version): redis_version = REDIS_INFO["version"] @@ -125,27 +172,47 @@ def _get_client(cls, request, single_connection_client=True, flushdb=True, redis_url = request.config.getoption("--redis-url") else: redis_url = from_url - url_options = parse_url(redis_url) - url_options.update(kwargs) - pool = redis.ConnectionPool(**url_options) - client = cls(connection_pool=pool) + cluster_mode = REDIS_INFO["cluster_enabled"] + if not cluster_mode: + url_options = parse_url(redis_url) + url_options.update(kwargs) + pool = redis.ConnectionPool(**url_options) + client = cls(connection_pool=pool) + else: + client = redis.RedisCluster.from_url(redis_url, **kwargs) + single_connection_client = False if single_connection_client: client = client.client() if request: def teardown(): - if flushdb: - try: - client.flushdb() - except redis.ConnectionError: - # handle cases where a test disconnected a client - # just manually retry the flushdb - client.flushdb() - client.close() - client.connection_pool.disconnect() + if not cluster_mode: + if flushdb: + try: + client.flushdb() + except redis.ConnectionError: + # handle cases where a test disconnected a client + # just manually retry the flushdb + client.flushdb() + client.close() + client.connection_pool.disconnect() + else: + cluster_teardown(client, flushdb) request.addfinalizer(teardown) return client +def cluster_teardown(client, flushdb): + if flushdb: + try: + client.flushdb(target_nodes='primaries') + except redis.ConnectionError: + # handle cases where a test disconnected a client + # just manually retry the flushdb + client.flushdb(target_nodes='primaries') + client.close() + client.disconnect_connection_pools() + + # specifically set to the zero database, because creating # an index on db != 0 raises a ResponseError in redis @pytest.fixture() diff --git a/tests/test_cluster.py b/tests/test_cluster.py new file mode 100644 index 0000000000..071cb7d2f1 --- /dev/null +++ b/tests/test_cluster.py @@ -0,0 +1,2482 @@ +import binascii +import datetime +import pytest +import warnings + +from time import sleep +from tests.test_pubsub import wait_for_message +from unittest.mock import call, patch, DEFAULT, Mock +from redis import Redis +from redis.cluster import get_node_name, ClusterNode, \ + RedisCluster, NodesManager, PRIMARY, REDIS_CLUSTER_HASH_SLOTS, REPLICA +from redis.commands import CommandsParser +from redis.connection import Connection +from redis.utils import str_if_bytes +from redis.exceptions import ( + AskError, + ClusterDownError, + DataError, + MovedError, + RedisClusterException, + RedisError +) + +from redis.crc import key_slot +from .conftest import ( + _get_client, + skip_if_server_version_lt, + skip_unless_arch_bits +) + +default_host = "127.0.0.1" +default_port = 7000 +default_cluster_slots = [ + [ + 0, 8191, + ['127.0.0.1', 7000, 'node_0'], + ['127.0.0.1', 7003, 'node_3'], + ], + [ + 8192, 16383, + ['127.0.0.1', 7001, 'node_1'], + ['127.0.0.1', 7002, 'node_2'] + ] +] + + +@pytest.fixture() +def slowlog(request, r): + """ + Set the slowlog threshold to 0, and the + max length to 128. This will force every + command into the slowlog and allow us + to test it + """ + # Save old values + current_config = r.config_get( + target_nodes=r.get_primaries()[0]) + old_slower_than_value = current_config['slowlog-log-slower-than'] + old_max_legnth_value = current_config['slowlog-max-len'] + + # Function to restore the old values + def cleanup(): + r.config_set('slowlog-log-slower-than', old_slower_than_value) + r.config_set('slowlog-max-len', old_max_legnth_value) + + request.addfinalizer(cleanup) + + # Set the new values + r.config_set('slowlog-log-slower-than', 0) + r.config_set('slowlog-max-len', 128) + + +def get_mocked_redis_client(func=None, *args, **kwargs): + """ + Return a stable RedisCluster object that have deterministic + nodes and slots setup to remove the problem of different IP addresses + on different installations and machines. + """ + cluster_slots = kwargs.pop('cluster_slots', default_cluster_slots) + coverage_res = kwargs.pop('coverage_result', 'yes') + with patch.object(Redis, 'execute_command') as execute_command_mock: + def execute_command(*_args, **_kwargs): + if _args[0] == 'CLUSTER SLOTS': + mock_cluster_slots = cluster_slots + return mock_cluster_slots + elif _args[0] == 'COMMAND': + return {'get': [], 'set': []} + elif _args[1] == 'cluster-require-full-coverage': + return {'cluster-require-full-coverage': coverage_res} + elif func is not None: + return func(*args, **kwargs) + else: + return execute_command_mock(*_args, **_kwargs) + + execute_command_mock.side_effect = execute_command + + with patch.object(CommandsParser, 'initialize', + autospec=True) as cmd_parser_initialize: + + def cmd_init_mock(self, r): + self.commands = {'get': {'name': 'get', 'arity': 2, + 'flags': ['readonly', + 'fast'], + 'first_key_pos': 1, + 'last_key_pos': 1, + 'step_count': 1}} + + cmd_parser_initialize.side_effect = cmd_init_mock + + return RedisCluster(*args, **kwargs) + + +def mock_node_resp(node, response): + connection = Mock() + connection.read_response.return_value = response + node.redis_connection.connection = connection + return node + + +def mock_node_resp_func(node, func): + connection = Mock() + connection.read_response.side_effect = func + node.redis_connection.connection = connection + return node + + +def mock_all_nodes_resp(rc, response): + for node in rc.get_nodes(): + mock_node_resp(node, response) + return rc + + +def find_node_ip_based_on_port(cluster_client, port): + for node in cluster_client.get_nodes(): + if node.port == port: + return node.host + + +def moved_redirection_helper(request, failover=False): + """ + Test that the client handles MOVED response after a failover. + Redirection after a failover means that the redirection address is of a + replica that was promoted to a primary. + + At first call it should return a MOVED ResponseError that will point + the client to the next server it should talk to. + + Verify that: + 1. it tries to talk to the redirected node + 2. it updates the slot's primary to the redirected node + + For a failover, also verify: + 3. the redirected node's server type updated to 'primary' + 4. the server type of the previous slot owner updated to 'replica' + """ + rc = _get_client(RedisCluster, request, flushdb=False) + slot = 12182 + redirect_node = None + # Get the current primary that holds this slot + prev_primary = rc.nodes_manager.get_node_from_slot(slot) + if failover: + if len(rc.nodes_manager.slots_cache[slot]) < 2: + warnings.warn("Skipping this test since it requires to have a " + "replica") + return + redirect_node = rc.nodes_manager.slots_cache[slot][1] + else: + # Use one of the primaries to be the redirected node + redirect_node = rc.get_primaries()[0] + r_host = redirect_node.host + r_port = redirect_node.port + with patch.object(Redis, 'parse_response') as parse_response: + def moved_redirect_effect(connection, *args, **options): + def ok_response(connection, *args, **options): + assert connection.host == r_host + assert connection.port == r_port + + return "MOCK_OK" + + parse_response.side_effect = ok_response + raise MovedError("{0} {1}:{2}".format(slot, r_host, r_port)) + + parse_response.side_effect = moved_redirect_effect + assert rc.execute_command("SET", "foo", "bar") == "MOCK_OK" + slot_primary = rc.nodes_manager.slots_cache[slot][0] + assert slot_primary == redirect_node + if failover: + assert rc.get_node(host=r_host, port=r_port).server_type == PRIMARY + assert prev_primary.server_type == REPLICA + + +@pytest.mark.onlycluster +class TestRedisClusterObj: + """ + Tests for the RedisCluster class + """ + + def test_host_port_startup_node(self): + """ + Test that it is possible to use host & port arguments as startup node + args + """ + cluster = get_mocked_redis_client(host=default_host, port=default_port) + assert cluster.get_node(host=default_host, + port=default_port) is not None + + def test_startup_nodes(self): + """ + Test that it is possible to use startup_nodes + argument to init the cluster + """ + port_1 = 7000 + port_2 = 7001 + startup_nodes = [ClusterNode(default_host, port_1), + ClusterNode(default_host, port_2)] + cluster = get_mocked_redis_client(startup_nodes=startup_nodes) + assert cluster.get_node(host=default_host, port=port_1) is not None \ + and cluster.get_node(host=default_host, port=port_2) is not None + + def test_empty_startup_nodes(self): + """ + Test that exception is raised when empty providing empty startup_nodes + """ + with pytest.raises(RedisClusterException) as ex: + RedisCluster(startup_nodes=[]) + + assert str(ex.value).startswith( + "RedisCluster requires at least one node to discover the " + "cluster"), str_if_bytes(ex.value) + + def test_from_url(self, r): + redis_url = "redis://{0}:{1}/0".format(default_host, default_port) + with patch.object(RedisCluster, 'from_url') as from_url: + def from_url_mocked(_url, **_kwargs): + return get_mocked_redis_client(url=_url, **_kwargs) + + from_url.side_effect = from_url_mocked + cluster = RedisCluster.from_url(redis_url) + assert cluster.get_node(host=default_host, + port=default_port) is not None + + def test_execute_command_errors(self, r): + """ + Test that if no key is provided then exception should be raised. + """ + with pytest.raises(RedisClusterException) as ex: + r.execute_command("GET") + assert str(ex.value).startswith("No way to dispatch this command to " + "Redis Cluster. Missing key.") + + def test_execute_command_node_flag_primaries(self, r): + """ + Test command execution with nodes flag PRIMARIES + """ + primaries = r.get_primaries() + replicas = r.get_replicas() + mock_all_nodes_resp(r, 'PONG') + assert r.ping(RedisCluster.PRIMARIES) is True + for primary in primaries: + conn = primary.redis_connection.connection + assert conn.read_response.called is True + for replica in replicas: + conn = replica.redis_connection.connection + assert conn.read_response.called is not True + + def test_execute_command_node_flag_replicas(self, r): + """ + Test command execution with nodes flag REPLICAS + """ + replicas = r.get_replicas() + if not replicas: + r = get_mocked_redis_client(default_host, default_port) + primaries = r.get_primaries() + mock_all_nodes_resp(r, 'PONG') + assert r.ping(RedisCluster.REPLICAS) is True + for replica in replicas: + conn = replica.redis_connection.connection + assert conn.read_response.called is True + for primary in primaries: + conn = primary.redis_connection.connection + assert conn.read_response.called is not True + + def test_execute_command_node_flag_all_nodes(self, r): + """ + Test command execution with nodes flag ALL_NODES + """ + mock_all_nodes_resp(r, 'PONG') + assert r.ping(RedisCluster.ALL_NODES) is True + for node in r.get_nodes(): + conn = node.redis_connection.connection + assert conn.read_response.called is True + + def test_execute_command_node_flag_random(self, r): + """ + Test command execution with nodes flag RANDOM + """ + mock_all_nodes_resp(r, 'PONG') + assert r.ping(RedisCluster.RANDOM) is True + called_count = 0 + for node in r.get_nodes(): + conn = node.redis_connection.connection + if conn.read_response.called is True: + called_count += 1 + assert called_count == 1 + + def test_execute_command_default_node(self, r): + """ + Test command execution without node flag is being executed on the + default node + """ + def_node = r.get_default_node() + mock_node_resp(def_node, 'PONG') + assert r.ping() is True + conn = def_node.redis_connection.connection + assert conn.read_response.called + + def test_ask_redirection(self, r): + """ + Test that the server handles ASK response. + + At first call it should return a ASK ResponseError that will point + the client to the next server it should talk to. + + Important thing to verify is that it tries to talk to the second node. + """ + redirect_node = r.get_nodes()[0] + with patch.object(Redis, 'parse_response') as parse_response: + def ask_redirect_effect(connection, *args, **options): + def ok_response(connection, *args, **options): + assert connection.host == redirect_node.host + assert connection.port == redirect_node.port + + return "MOCK_OK" + + parse_response.side_effect = ok_response + raise AskError("12182 {0}:{1}".format(redirect_node.host, + redirect_node.port)) + + parse_response.side_effect = ask_redirect_effect + + assert r.execute_command("SET", "foo", "bar") == "MOCK_OK" + + def test_moved_redirection(self, request): + """ + Test that the client handles MOVED response. + """ + moved_redirection_helper(request, failover=False) + + def test_moved_redirection_after_failover(self, request): + """ + Test that the client handles MOVED response after a failover. + """ + moved_redirection_helper(request, failover=True) + + def test_refresh_using_specific_nodes(self, request): + """ + Test making calls on specific nodes when the cluster has failed over to + another node + """ + node_7006 = ClusterNode(host=default_host, port=7006, + server_type=PRIMARY) + node_7007 = ClusterNode(host=default_host, port=7007, + server_type=PRIMARY) + with patch.object(Redis, 'parse_response') as parse_response: + with patch.object(NodesManager, 'initialize', autospec=True) as \ + initialize: + with patch.multiple(Connection, + send_command=DEFAULT, + connect=DEFAULT, + can_read=DEFAULT) as mocks: + # simulate 7006 as a failed node + def parse_response_mock(connection, command_name, + **options): + if connection.port == 7006: + parse_response.failed_calls += 1 + raise ClusterDownError( + 'CLUSTERDOWN The cluster is ' + 'down. Use CLUSTER INFO for ' + 'more information') + elif connection.port == 7007: + parse_response.successful_calls += 1 + + def initialize_mock(self): + # start with all slots mapped to 7006 + self.nodes_cache = {node_7006.name: node_7006} + self.default_node = node_7006 + self.slots_cache = {} + + for i in range(0, 16383): + self.slots_cache[i] = [node_7006] + + # After the first connection fails, a reinitialize + # should follow the cluster to 7007 + def map_7007(self): + self.nodes_cache = { + node_7007.name: node_7007} + self.default_node = node_7007 + self.slots_cache = {} + + for i in range(0, 16383): + self.slots_cache[i] = [node_7007] + + # Change initialize side effect for the second call + initialize.side_effect = map_7007 + + parse_response.side_effect = parse_response_mock + parse_response.successful_calls = 0 + parse_response.failed_calls = 0 + initialize.side_effect = initialize_mock + mocks['can_read'].return_value = False + mocks['send_command'].return_value = "MOCK_OK" + mocks['connect'].return_value = None + with patch.object(CommandsParser, 'initialize', + autospec=True) as cmd_parser_initialize: + + def cmd_init_mock(self, r): + self.commands = {'get': {'name': 'get', 'arity': 2, + 'flags': ['readonly', + 'fast'], + 'first_key_pos': 1, + 'last_key_pos': 1, + 'step_count': 1}} + + cmd_parser_initialize.side_effect = cmd_init_mock + + rc = _get_client( + RedisCluster, request, flushdb=False) + assert len(rc.get_nodes()) == 1 + assert rc.get_node(node_name=node_7006.name) is not \ + None + + rc.get('foo') + + # Cluster should now point to 7007, and there should be + # one failed and one successful call + assert len(rc.get_nodes()) == 1 + assert rc.get_node(node_name=node_7007.name) is not \ + None + assert rc.get_node(node_name=node_7006.name) is None + assert parse_response.failed_calls == 1 + assert parse_response.successful_calls == 1 + + def test_reading_from_replicas_in_round_robin(self): + with patch.multiple(Connection, send_command=DEFAULT, + read_response=DEFAULT, _connect=DEFAULT, + can_read=DEFAULT, on_connect=DEFAULT) as mocks: + with patch.object(Redis, 'parse_response') as parse_response: + def parse_response_mock_first(connection, *args, **options): + # Primary + assert connection.port == 7001 + parse_response.side_effect = parse_response_mock_second + return "MOCK_OK" + + def parse_response_mock_second(connection, *args, **options): + # Replica + assert connection.port == 7002 + parse_response.side_effect = parse_response_mock_third + return "MOCK_OK" + + def parse_response_mock_third(connection, *args, **options): + # Primary + assert connection.port == 7001 + return "MOCK_OK" + + # We don't need to create a real cluster connection but we + # do want RedisCluster.on_connect function to get called, + # so we'll mock some of the Connection's functions to allow it + parse_response.side_effect = parse_response_mock_first + mocks['send_command'].return_value = True + mocks['read_response'].return_value = "OK" + mocks['_connect'].return_value = True + mocks['can_read'].return_value = False + mocks['on_connect'].return_value = True + + # Create a cluster with reading from replications + read_cluster = get_mocked_redis_client(host=default_host, + port=default_port, + read_from_replicas=True) + assert read_cluster.read_from_replicas is True + # Check that we read from the slot's nodes in a round robin + # matter. + # 'foo' belongs to slot 12182 and the slot's nodes are: + # [(127.0.0.1,7001,primary), (127.0.0.1,7002,replica)] + read_cluster.get("foo") + read_cluster.get("foo") + read_cluster.get("foo") + mocks['send_command'].assert_has_calls([call('READONLY')]) + + def test_keyslot(self, r): + """ + Test that method will compute correct key in all supported cases + """ + assert r.keyslot("foo") == 12182 + assert r.keyslot("{foo}bar") == 12182 + assert r.keyslot("{foo}") == 12182 + assert r.keyslot(1337) == 4314 + + assert r.keyslot(125) == r.keyslot(b"125") + assert r.keyslot(125) == r.keyslot("\x31\x32\x35") + assert r.keyslot("大奖") == r.keyslot(b"\xe5\xa4\xa7\xe5\xa5\x96") + assert r.keyslot(u"大奖") == r.keyslot(b"\xe5\xa4\xa7\xe5\xa5\x96") + assert r.keyslot(1337.1234) == r.keyslot("1337.1234") + assert r.keyslot(1337) == r.keyslot("1337") + assert r.keyslot(b"abc") == r.keyslot("abc") + + def test_get_node_name(self): + assert get_node_name(default_host, default_port) == \ + "{0}:{1}".format(default_host, default_port) + + def test_all_nodes(self, r): + """ + Set a list of nodes and it should be possible to iterate over all + """ + nodes = [node for node in r.nodes_manager.nodes_cache.values()] + + for i, node in enumerate(r.get_nodes()): + assert node in nodes + + def test_all_nodes_masters(self, r): + """ + Set a list of nodes with random primaries/replicas config and it shold + be possible to iterate over all of them. + """ + nodes = [node for node in r.nodes_manager.nodes_cache.values() + if node.server_type == PRIMARY] + + for node in r.get_primaries(): + assert node in nodes + + def test_cluster_down_overreaches_retry_attempts(self): + """ + When ClusterDownError is thrown, test that we retry executing the + command as many times as configured in cluster_error_retry_attempts + and then raise the exception + """ + with patch.object(RedisCluster, '_execute_command') as execute_command: + def raise_cluster_down_error(target_node, *args, **kwargs): + execute_command.failed_calls += 1 + raise ClusterDownError( + 'CLUSTERDOWN The cluster is down. Use CLUSTER INFO for ' + 'more information') + + execute_command.side_effect = raise_cluster_down_error + + rc = get_mocked_redis_client(host=default_host, port=default_port) + + with pytest.raises(ClusterDownError): + rc.get("bar") + assert execute_command.failed_calls == \ + rc.cluster_error_retry_attempts + + def test_connection_error_overreaches_retry_attempts(self): + """ + When ConnectionError is thrown, test that we retry executing the + command as many times as configured in cluster_error_retry_attempts + and then raise the exception + """ + with patch.object(RedisCluster, '_execute_command') as execute_command: + def raise_conn_error(target_node, *args, **kwargs): + execute_command.failed_calls += 1 + raise ConnectionError() + + execute_command.side_effect = raise_conn_error + + rc = get_mocked_redis_client(host=default_host, port=default_port) + + with pytest.raises(ConnectionError): + rc.get("bar") + assert execute_command.failed_calls == \ + rc.cluster_error_retry_attempts + + def test_user_on_connect_function(self, request): + """ + Test support in passing on_connect function by the user + """ + + def on_connect(connection): + assert connection is not None + + mock = Mock(side_effect=on_connect) + + _get_client(RedisCluster, request, redis_connect_func=mock) + assert mock.called is True + + def test_set_default_node_success(self, r): + """ + test successful replacement of the default cluster node + """ + default_node = r.get_default_node() + # get a different node + new_def_node = None + for node in r.get_nodes(): + if node != default_node: + new_def_node = node + break + assert r.set_default_node(new_def_node) is True + assert r.get_default_node() == new_def_node + + def test_set_default_node_failure(self, r): + """ + test failed replacement of the default cluster node + """ + default_node = r.get_default_node() + new_def_node = ClusterNode('1.1.1.1', 1111) + assert r.set_default_node(None) is False + assert r.set_default_node(new_def_node) is False + assert r.get_default_node() == default_node + + def test_get_node_from_key(self, r): + """ + Test that get_node_from_key function returns the correct node + """ + key = 'bar' + slot = r.keyslot(key) + slot_nodes = r.nodes_manager.slots_cache.get(slot) + primary = slot_nodes[0] + assert r.get_node_from_key(key, replica=False) == primary + replica = r.get_node_from_key(key, replica=True) + if replica is not None: + assert replica.server_type == REPLICA + assert replica in slot_nodes + + +@pytest.mark.onlycluster +class TestClusterRedisCommands: + """ + Tests for RedisCluster unique commands + """ + + def test_case_insensitive_command_names(self, r): + assert r.cluster_response_callbacks['cluster addslots'] == \ + r.cluster_response_callbacks['CLUSTER ADDSLOTS'] + + def test_get_and_set(self, r): + # get and set can't be tested independently of each other + assert r.get('a') is None + byte_string = b'value' + integer = 5 + unicode_string = chr(3456) + 'abcd' + chr(3421) + assert r.set('byte_string', byte_string) + assert r.set('integer', 5) + assert r.set('unicode_string', unicode_string) + assert r.get('byte_string') == byte_string + assert r.get('integer') == str(integer).encode() + assert r.get('unicode_string').decode('utf-8') == unicode_string + + def test_mget_nonatomic(self, r): + assert r.mget_nonatomic([]) == [] + assert r.mget_nonatomic(['a', 'b']) == [None, None] + r['a'] = '1' + r['b'] = '2' + r['c'] = '3' + + assert (r.mget_nonatomic('a', 'other', 'b', 'c') == + [b'1', None, b'2', b'3']) + + def test_mset_nonatomic(self, r): + d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + assert r.mset_nonatomic(d) + for k, v in d.items(): + assert r[k] == v + + def test_config_set(self, r): + assert r.config_set('slowlog-log-slower-than', 0) + + def test_cluster_config_resetstat(self, r): + r.ping(target_nodes='all') + all_info = r.info(target_nodes='all') + prior_commands_processed = -1 + for node_info in all_info.values(): + prior_commands_processed = node_info['total_commands_processed'] + assert prior_commands_processed >= 1 + r.config_resetstat(target_nodes='all') + all_info = r.info(target_nodes='all') + for node_info in all_info.values(): + reset_commands_processed = node_info['total_commands_processed'] + assert reset_commands_processed < prior_commands_processed + + def test_client_setname(self, r): + node = r.get_random_node() + r.client_setname('redis_py_test', target_nodes=node) + client_name = r.client_getname(target_nodes=node) + assert client_name == 'redis_py_test' + + def test_exists(self, r): + d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + r.mset_nonatomic(d) + assert r.exists(*d.keys()) == len(d) + + def test_delete(self, r): + d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + r.mset_nonatomic(d) + assert r.delete(*d.keys()) == len(d) + assert r.delete(*d.keys()) == 0 + + def test_touch(self, r): + d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + r.mset_nonatomic(d) + assert r.touch(*d.keys()) == len(d) + + def test_unlink(self, r): + d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + r.mset_nonatomic(d) + assert r.unlink(*d.keys()) == len(d) + # Unlink is non-blocking so we sleep before + # verifying the deletion + sleep(0.1) + assert r.unlink(*d.keys()) == 0 + + def test_pubsub_channels_merge_results(self, r): + nodes = r.get_nodes() + channels = [] + pubsub_nodes = [] + i = 0 + for node in nodes: + channel = "foo{0}".format(i) + # We will create different pubsub clients where each one is + # connected to a different node + p = r.pubsub(node) + pubsub_nodes.append(p) + p.subscribe(channel) + b_channel = channel.encode('utf-8') + channels.append(b_channel) + # Assert that each node returns only the channel it subscribed to + sub_channels = node.redis_connection.pubsub_channels() + if not sub_channels: + # Try again after a short sleep + sleep(0.3) + sub_channels = node.redis_connection.pubsub_channels() + assert sub_channels == [b_channel] + i += 1 + # Assert that the cluster's pubsub_channels function returns ALL of + # the cluster's channels + result = r.pubsub_channels(target_nodes='all') + result.sort() + assert result == channels + + def test_pubsub_numsub_merge_results(self, r): + nodes = r.get_nodes() + pubsub_nodes = [] + channel = "foo" + b_channel = channel.encode('utf-8') + for node in nodes: + # We will create different pubsub clients where each one is + # connected to a different node + p = r.pubsub(node) + pubsub_nodes.append(p) + p.subscribe(channel) + # Assert that each node returns that only one client is subscribed + sub_chann_num = node.redis_connection.pubsub_numsub(channel) + if sub_chann_num == [(b_channel, 0)]: + sleep(0.3) + sub_chann_num = node.redis_connection.pubsub_numsub(channel) + assert sub_chann_num == [(b_channel, 1)] + # Assert that the cluster's pubsub_numsub function returns ALL clients + # subscribed to this channel in the entire cluster + assert r.pubsub_numsub(channel, target_nodes='all') == \ + [(b_channel, len(nodes))] + + def test_pubsub_numpat_merge_results(self, r): + nodes = r.get_nodes() + pubsub_nodes = [] + pattern = "foo*" + for node in nodes: + # We will create different pubsub clients where each one is + # connected to a different node + p = r.pubsub(node) + pubsub_nodes.append(p) + p.psubscribe(pattern) + # Assert that each node returns that only one client is subscribed + sub_num_pat = node.redis_connection.pubsub_numpat() + if sub_num_pat == 0: + sleep(0.3) + sub_num_pat = node.redis_connection.pubsub_numpat() + assert sub_num_pat == 1 + # Assert that the cluster's pubsub_numsub function returns ALL clients + # subscribed to this channel in the entire cluster + assert r.pubsub_numpat(target_nodes='all') == len(nodes) + + @skip_if_server_version_lt('2.8.0') + def test_cluster_pubsub_channels(self, r): + p = r.pubsub() + p.subscribe('foo', 'bar', 'baz', 'quux') + for i in range(4): + assert wait_for_message(p, timeout=0.5)['type'] == 'subscribe' + expected = [b'bar', b'baz', b'foo', b'quux'] + assert all([channel in r.pubsub_channels(target_nodes='all') + for channel in expected]) + + @skip_if_server_version_lt('2.8.0') + def test_cluster_pubsub_numsub(self, r): + p1 = r.pubsub() + p1.subscribe('foo', 'bar', 'baz') + for i in range(3): + assert wait_for_message(p1, timeout=0.5)['type'] == 'subscribe' + p2 = r.pubsub() + p2.subscribe('bar', 'baz') + for i in range(2): + assert wait_for_message(p2, timeout=0.5)['type'] == 'subscribe' + p3 = r.pubsub() + p3.subscribe('baz') + assert wait_for_message(p3, timeout=0.5)['type'] == 'subscribe' + + channels = [(b'foo', 1), (b'bar', 2), (b'baz', 3)] + assert r.pubsub_numsub('foo', 'bar', 'baz', target_nodes='all') \ + == channels + + def test_cluster_slots(self, r): + mock_all_nodes_resp(r, default_cluster_slots) + cluster_slots = r.cluster_slots() + assert isinstance(cluster_slots, dict) + assert len(default_cluster_slots) == len(cluster_slots) + assert cluster_slots.get((0, 8191)) is not None + assert cluster_slots.get((0, 8191)).get('primary') == \ + ('127.0.0.1', 7000) + + def test_cluster_addslots(self, r): + node = r.get_random_node() + mock_node_resp(node, 'OK') + assert r.cluster_addslots(node, 1, 2, 3) is True + + def test_cluster_countkeysinslot(self, r): + node = r.nodes_manager.get_node_from_slot(1) + mock_node_resp(node, 2) + assert r.cluster_countkeysinslot(1) == 2 + + def test_cluster_count_failure_report(self, r): + mock_all_nodes_resp(r, 0) + assert r.cluster_count_failure_report('node_0') == 0 + + def test_cluster_delslots(self): + cluster_slots = [ + [ + 0, 8191, + ['127.0.0.1', 7000, 'node_0'], + ], + [ + 8192, 16383, + ['127.0.0.1', 7001, 'node_1'], + ] + ] + r = get_mocked_redis_client(host=default_host, port=default_port, + cluster_slots=cluster_slots) + mock_all_nodes_resp(r, 'OK') + node0 = r.get_node(default_host, 7000) + node1 = r.get_node(default_host, 7001) + assert r.cluster_delslots(0, 8192) == [True, True] + assert node0.redis_connection.connection.read_response.called + assert node1.redis_connection.connection.read_response.called + + def test_cluster_failover(self, r): + node = r.get_random_node() + mock_node_resp(node, 'OK') + assert r.cluster_failover(node) is True + assert r.cluster_failover(node, 'FORCE') is True + assert r.cluster_failover(node, 'TAKEOVER') is True + with pytest.raises(RedisError): + r.cluster_failover(node, 'FORCT') + + def test_cluster_info(self, r): + info = r.cluster_info() + assert isinstance(info, dict) + assert info['cluster_state'] == 'ok' + + def test_cluster_keyslot(self, r): + mock_all_nodes_resp(r, 12182) + assert r.cluster_keyslot('foo') == 12182 + + def test_cluster_meet(self, r): + node = r.get_default_node() + mock_node_resp(node, 'OK') + assert r.cluster_meet('127.0.0.1', 6379) is True + + def test_cluster_nodes(self, r): + response = ( + 'c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 ' + 'slave aa90da731f673a99617dfe930306549a09f83a6b 0 ' + '1447836263059 5 connected\n' + '9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 ' + 'master - 0 1447836264065 0 connected\n' + 'aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 ' + 'myself,master - 0 0 2 connected 5461-10922\n' + '1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 ' + 'slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 ' + '1447836262556 3 connected\n' + '4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 ' + 'master - 0 1447836262555 7 connected 0-5460\n' + '19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 ' + 'master - 0 1447836263562 3 connected 10923-16383\n' + 'fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 ' + 'master,fail - 1447829446956 1447829444948 1 disconnected\n' + ) + mock_all_nodes_resp(r, response) + nodes = r.cluster_nodes() + assert len(nodes) == 7 + assert nodes.get('172.17.0.7:7006') is not None + assert nodes.get('172.17.0.7:7006').get('node_id') == \ + "c8253bae761cb1ecb2b61857d85dfe455a0fec8b" + + def test_cluster_replicate(self, r): + node = r.get_random_node() + all_replicas = r.get_replicas() + mock_all_nodes_resp(r, 'OK') + assert r.cluster_replicate(node, 'c8253bae761cb61857d') is True + results = r.cluster_replicate(all_replicas, 'c8253bae761cb61857d') + if isinstance(results, dict): + for res in results.values(): + assert res is True + else: + assert results is True + + def test_cluster_reset(self, r): + mock_all_nodes_resp(r, 'OK') + assert r.cluster_reset() is True + assert r.cluster_reset(False) is True + all_results = r.cluster_reset(False, target_nodes='all') + for res in all_results.values(): + assert res is True + + def test_cluster_save_config(self, r): + node = r.get_random_node() + all_nodes = r.get_nodes() + mock_all_nodes_resp(r, 'OK') + assert r.cluster_save_config(node) is True + all_results = r.cluster_save_config(all_nodes) + for res in all_results.values(): + assert res is True + + def test_cluster_get_keys_in_slot(self, r): + response = [b'{foo}1', b'{foo}2'] + node = r.nodes_manager.get_node_from_slot(12182) + mock_node_resp(node, response) + keys = r.cluster_get_keys_in_slot(12182, 4) + assert keys == response + + def test_cluster_set_config_epoch(self, r): + mock_all_nodes_resp(r, 'OK') + assert r.cluster_set_config_epoch(3) is True + all_results = r.cluster_set_config_epoch(3, target_nodes='all') + for res in all_results.values(): + assert res is True + + def test_cluster_setslot(self, r): + node = r.get_random_node() + mock_node_resp(node, 'OK') + assert r.cluster_setslot(node, 'node_0', 1218, 'IMPORTING') is True + assert r.cluster_setslot(node, 'node_0', 1218, 'NODE') is True + assert r.cluster_setslot(node, 'node_0', 1218, 'MIGRATING') is True + with pytest.raises(RedisError): + r.cluster_failover(node, 'STABLE') + with pytest.raises(RedisError): + r.cluster_failover(node, 'STATE') + + def test_cluster_setslot_stable(self, r): + node = r.nodes_manager.get_node_from_slot(12182) + mock_node_resp(node, 'OK') + assert r.cluster_setslot_stable(12182) is True + assert node.redis_connection.connection.read_response.called + + def test_cluster_replicas(self, r): + response = [b'01eca22229cf3c652b6fca0d09ff6941e0d2e3 ' + b'127.0.0.1:6377@16377 slave ' + b'52611e796814b78e90ad94be9d769a4f668f9a 0 ' + b'1634550063436 4 connected', + b'r4xfga22229cf3c652b6fca0d09ff69f3e0d4d ' + b'127.0.0.1:6378@16378 slave ' + b'52611e796814b78e90ad94be9d769a4f668f9a 0 ' + b'1634550063436 4 connected'] + mock_all_nodes_resp(r, response) + replicas = r.cluster_replicas('52611e796814b78e90ad94be9d769a4f668f9a') + assert replicas.get('127.0.0.1:6377') is not None + assert replicas.get('127.0.0.1:6378') is not None + assert replicas.get('127.0.0.1:6378').get('node_id') == \ + 'r4xfga22229cf3c652b6fca0d09ff69f3e0d4d' + + def test_readonly(self): + r = get_mocked_redis_client(host=default_host, port=default_port) + mock_all_nodes_resp(r, 'OK') + assert r.readonly() is True + all_replicas_results = r.readonly(target_nodes='replicas') + for res in all_replicas_results.values(): + assert res is True + for replica in r.get_replicas(): + assert replica.redis_connection.connection.read_response.called + + def test_readwrite(self): + r = get_mocked_redis_client(host=default_host, port=default_port) + mock_all_nodes_resp(r, 'OK') + assert r.readwrite() is True + all_replicas_results = r.readwrite(target_nodes='replicas') + for res in all_replicas_results.values(): + assert res is True + for replica in r.get_replicas(): + assert replica.redis_connection.connection.read_response.called + + def test_bgsave(self, r): + assert r.bgsave() + sleep(0.3) + assert r.bgsave(True) + + def test_info(self, r): + # Map keys to same slot + r.set('x{1}', 1) + r.set('y{1}', 2) + r.set('z{1}', 3) + # Get node that handles the slot + slot = r.keyslot('x{1}') + node = r.nodes_manager.get_node_from_slot(slot) + # Run info on that node + info = r.info(target_nodes=node) + assert isinstance(info, dict) + assert info['db0']['keys'] == 3 + + def _init_slowlog_test(self, r, node): + slowlog_lim = r.config_get('slowlog-log-slower-than', + target_nodes=node) + assert r.config_set('slowlog-log-slower-than', 0, target_nodes=node) \ + is True + return slowlog_lim['slowlog-log-slower-than'] + + def _teardown_slowlog_test(self, r, node, prev_limit): + assert r.config_set('slowlog-log-slower-than', prev_limit, + target_nodes=node) is True + + def test_slowlog_get(self, r, slowlog): + unicode_string = chr(3456) + 'abcd' + chr(3421) + node = r.get_node_from_key(unicode_string) + slowlog_limit = self._init_slowlog_test(r, node) + assert r.slowlog_reset(target_nodes=node) + r.get(unicode_string) + slowlog = r.slowlog_get(target_nodes=node) + assert isinstance(slowlog, list) + commands = [log['command'] for log in slowlog] + + get_command = b' '.join((b'GET', unicode_string.encode('utf-8'))) + assert get_command in commands + assert b'SLOWLOG RESET' in commands + + # the order should be ['GET ', 'SLOWLOG RESET'], + # but if other clients are executing commands at the same time, there + # could be commands, before, between, or after, so just check that + # the two we care about are in the appropriate order. + assert commands.index(get_command) < commands.index(b'SLOWLOG RESET') + + # make sure other attributes are typed correctly + assert isinstance(slowlog[0]['start_time'], int) + assert isinstance(slowlog[0]['duration'], int) + # rollback the slowlog limit to its original value + self._teardown_slowlog_test(r, node, slowlog_limit) + + def test_slowlog_get_limit(self, r, slowlog): + assert r.slowlog_reset() + node = r.get_node_from_key('foo') + slowlog_limit = self._init_slowlog_test(r, node) + r.get('foo') + slowlog = r.slowlog_get(1, target_nodes=node) + assert isinstance(slowlog, list) + # only one command, based on the number we passed to slowlog_get() + assert len(slowlog) == 1 + self._teardown_slowlog_test(r, node, slowlog_limit) + + def test_slowlog_length(self, r, slowlog): + r.get('foo') + node = r.nodes_manager.get_node_from_slot(key_slot(b'foo')) + slowlog_len = r.slowlog_len(target_nodes=node) + assert isinstance(slowlog_len, int) + + def test_time(self, r): + t = r.time(target_nodes=r.get_primaries()[0]) + assert len(t) == 2 + assert isinstance(t[0], int) + assert isinstance(t[1], int) + + @skip_if_server_version_lt('4.0.0') + def test_memory_usage(self, r): + r.set('foo', 'bar') + assert isinstance(r.memory_usage('foo'), int) + + @skip_if_server_version_lt('4.0.0') + def test_memory_malloc_stats(self, r): + assert r.memory_malloc_stats() + + @skip_if_server_version_lt('4.0.0') + def test_memory_stats(self, r): + # put a key into the current db to make sure that "db." + # has data + r.set('foo', 'bar') + node = r.nodes_manager.get_node_from_slot(key_slot(b'foo')) + stats = r.memory_stats(target_nodes=node) + assert isinstance(stats, dict) + for key, value in stats.items(): + if key.startswith('db.'): + assert isinstance(value, dict) + + @skip_if_server_version_lt('4.0.0') + def test_memory_help(self, r): + with pytest.raises(NotImplementedError): + r.memory_help() + + @skip_if_server_version_lt('4.0.0') + def test_memory_doctor(self, r): + with pytest.raises(NotImplementedError): + r.memory_doctor() + + def test_lastsave(self, r): + node = r.get_primaries()[0] + assert isinstance(r.lastsave(target_nodes=node), + datetime.datetime) + + def test_cluster_echo(self, r): + node = r.get_primaries()[0] + assert r.echo('foo bar', node) == b'foo bar' + + @skip_if_server_version_lt('1.0.0') + def test_debug_segfault(self, r): + with pytest.raises(NotImplementedError): + r.debug_segfault() + + def test_config_resetstat(self, r): + node = r.get_primaries()[0] + r.ping(target_nodes=node) + prior_commands_processed = \ + int(r.info(target_nodes=node)['total_commands_processed']) + assert prior_commands_processed >= 1 + r.config_resetstat(target_nodes=node) + reset_commands_processed = \ + int(r.info(target_nodes=node)['total_commands_processed']) + assert reset_commands_processed < prior_commands_processed + + @skip_if_server_version_lt('6.2.0') + def test_client_trackinginfo(self, r): + node = r.get_primaries()[0] + res = r.client_trackinginfo(target_nodes=node) + assert len(res) > 2 + assert 'prefixes' in res + + @skip_if_server_version_lt('2.9.50') + def test_client_pause(self, r): + node = r.get_primaries()[0] + assert r.client_pause(1, target_nodes=node) + assert r.client_pause(timeout=1, target_nodes=node) + with pytest.raises(RedisError): + r.client_pause(timeout='not an integer', target_nodes=node) + + @skip_if_server_version_lt('6.2.0') + def test_client_unpause(self, r): + assert r.client_unpause() + + @skip_if_server_version_lt('5.0.0') + def test_client_id(self, r): + node = r.get_primaries()[0] + assert r.client_id(target_nodes=node) > 0 + + @skip_if_server_version_lt('5.0.0') + def test_client_unblock(self, r): + node = r.get_primaries()[0] + myid = r.client_id(target_nodes=node) + assert not r.client_unblock(myid, target_nodes=node) + assert not r.client_unblock(myid, error=True, target_nodes=node) + assert not r.client_unblock(myid, error=False, target_nodes=node) + + @skip_if_server_version_lt('6.0.0') + def test_client_getredir(self, r): + node = r.get_primaries()[0] + assert isinstance(r.client_getredir(target_nodes=node), int) + assert r.client_getredir(target_nodes=node) == -1 + + @skip_if_server_version_lt('6.2.0') + def test_client_info(self, r): + node = r.get_primaries()[0] + info = r.client_info(target_nodes=node) + assert isinstance(info, dict) + assert 'addr' in info + + @skip_if_server_version_lt('2.6.9') + def test_client_kill(self, r, r2): + node = r.get_primaries()[0] + r.client_setname('redis-py-c1', target_nodes='all') + r2.client_setname('redis-py-c2', target_nodes='all') + clients = [client for client in r.client_list(target_nodes=node) + if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + assert len(clients) == 2 + clients_by_name = dict([(client.get('name'), client) + for client in clients]) + + client_addr = clients_by_name['redis-py-c2'].get('addr') + assert r.client_kill(client_addr, target_nodes=node) is True + + clients = [client for client in r.client_list(target_nodes=node) + if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + assert len(clients) == 1 + assert clients[0].get('name') == 'redis-py-c1' + + @skip_if_server_version_lt('2.6.0') + def test_cluster_bitop_not_empty_string(self, r): + r['{foo}a'] = '' + r.bitop('not', '{foo}r', '{foo}a') + assert r.get('{foo}r') is None + + @skip_if_server_version_lt('2.6.0') + def test_cluster_bitop_not(self, r): + test_str = b'\xAA\x00\xFF\x55' + correct = ~0xAA00FF55 & 0xFFFFFFFF + r['{foo}a'] = test_str + r.bitop('not', '{foo}r', '{foo}a') + assert int(binascii.hexlify(r['{foo}r']), 16) == correct + + @skip_if_server_version_lt('2.6.0') + def test_cluster_bitop_not_in_place(self, r): + test_str = b'\xAA\x00\xFF\x55' + correct = ~0xAA00FF55 & 0xFFFFFFFF + r['{foo}a'] = test_str + r.bitop('not', '{foo}a', '{foo}a') + assert int(binascii.hexlify(r['{foo}a']), 16) == correct + + @skip_if_server_version_lt('2.6.0') + def test_cluster_bitop_single_string(self, r): + test_str = b'\x01\x02\xFF' + r['{foo}a'] = test_str + r.bitop('and', '{foo}res1', '{foo}a') + r.bitop('or', '{foo}res2', '{foo}a') + r.bitop('xor', '{foo}res3', '{foo}a') + assert r['{foo}res1'] == test_str + assert r['{foo}res2'] == test_str + assert r['{foo}res3'] == test_str + + @skip_if_server_version_lt('2.6.0') + def test_cluster_bitop_string_operands(self, r): + r['{foo}a'] = b'\x01\x02\xFF\xFF' + r['{foo}b'] = b'\x01\x02\xFF' + r.bitop('and', '{foo}res1', '{foo}a', '{foo}b') + r.bitop('or', '{foo}res2', '{foo}a', '{foo}b') + r.bitop('xor', '{foo}res3', '{foo}a', '{foo}b') + assert int(binascii.hexlify(r['{foo}res1']), 16) == 0x0102FF00 + assert int(binascii.hexlify(r['{foo}res2']), 16) == 0x0102FFFF + assert int(binascii.hexlify(r['{foo}res3']), 16) == 0x000000FF + + @skip_if_server_version_lt('6.2.0') + def test_cluster_copy(self, r): + assert r.copy("{foo}a", "{foo}b") == 0 + r.set("{foo}a", "bar") + assert r.copy("{foo}a", "{foo}b") == 1 + assert r.get("{foo}a") == b"bar" + assert r.get("{foo}b") == b"bar" + + @skip_if_server_version_lt('6.2.0') + def test_cluster_copy_and_replace(self, r): + r.set("{foo}a", "foo1") + r.set("{foo}b", "foo2") + assert r.copy("{foo}a", "{foo}b") == 0 + assert r.copy("{foo}a", "{foo}b", replace=True) == 1 + + @skip_if_server_version_lt('6.2.0') + def test_cluster_lmove(self, r): + r.rpush('{foo}a', 'one', 'two', 'three', 'four') + assert r.lmove('{foo}a', '{foo}b') + assert r.lmove('{foo}a', '{foo}b', 'right', 'left') + + @skip_if_server_version_lt('6.2.0') + def test_cluster_blmove(self, r): + r.rpush('{foo}a', 'one', 'two', 'three', 'four') + assert r.blmove('{foo}a', '{foo}b', 5) + assert r.blmove('{foo}a', '{foo}b', 1, 'RIGHT', 'LEFT') + + def test_cluster_msetnx(self, r): + d = {'{foo}a': b'1', '{foo}b': b'2', '{foo}c': b'3'} + assert r.msetnx(d) + d2 = {'{foo}a': b'x', '{foo}d': b'4'} + assert not r.msetnx(d2) + for k, v in d.items(): + assert r[k] == v + assert r.get('{foo}d') is None + + def test_cluster_rename(self, r): + r['{foo}a'] = '1' + assert r.rename('{foo}a', '{foo}b') + assert r.get('{foo}a') is None + assert r['{foo}b'] == b'1' + + def test_cluster_renamenx(self, r): + r['{foo}a'] = '1' + r['{foo}b'] = '2' + assert not r.renamenx('{foo}a', '{foo}b') + assert r['{foo}a'] == b'1' + assert r['{foo}b'] == b'2' + + # LIST COMMANDS + def test_cluster_blpop(self, r): + r.rpush('{foo}a', '1', '2') + r.rpush('{foo}b', '3', '4') + assert r.blpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}b', b'3') + assert r.blpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}b', b'4') + assert r.blpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}a', b'1') + assert r.blpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}a', b'2') + assert r.blpop(['{foo}b', '{foo}a'], timeout=1) is None + r.rpush('{foo}c', '1') + assert r.blpop('{foo}c', timeout=1) == (b'{foo}c', b'1') + + def test_cluster_brpop(self, r): + r.rpush('{foo}a', '1', '2') + r.rpush('{foo}b', '3', '4') + assert r.brpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}b', b'4') + assert r.brpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}b', b'3') + assert r.brpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}a', b'2') + assert r.brpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}a', b'1') + assert r.brpop(['{foo}b', '{foo}a'], timeout=1) is None + r.rpush('{foo}c', '1') + assert r.brpop('{foo}c', timeout=1) == (b'{foo}c', b'1') + + def test_cluster_brpoplpush(self, r): + r.rpush('{foo}a', '1', '2') + r.rpush('{foo}b', '3', '4') + assert r.brpoplpush('{foo}a', '{foo}b') == b'2' + assert r.brpoplpush('{foo}a', '{foo}b') == b'1' + assert r.brpoplpush('{foo}a', '{foo}b', timeout=1) is None + assert r.lrange('{foo}a', 0, -1) == [] + assert r.lrange('{foo}b', 0, -1) == [b'1', b'2', b'3', b'4'] + + def test_cluster_brpoplpush_empty_string(self, r): + r.rpush('{foo}a', '') + assert r.brpoplpush('{foo}a', '{foo}b') == b'' + + def test_cluster_rpoplpush(self, r): + r.rpush('{foo}a', 'a1', 'a2', 'a3') + r.rpush('{foo}b', 'b1', 'b2', 'b3') + assert r.rpoplpush('{foo}a', '{foo}b') == b'a3' + assert r.lrange('{foo}a', 0, -1) == [b'a1', b'a2'] + assert r.lrange('{foo}b', 0, -1) == [b'a3', b'b1', b'b2', b'b3'] + + def test_cluster_sdiff(self, r): + r.sadd('{foo}a', '1', '2', '3') + assert r.sdiff('{foo}a', '{foo}b') == {b'1', b'2', b'3'} + r.sadd('{foo}b', '2', '3') + assert r.sdiff('{foo}a', '{foo}b') == {b'1'} + + def test_cluster_sdiffstore(self, r): + r.sadd('{foo}a', '1', '2', '3') + assert r.sdiffstore('{foo}c', '{foo}a', '{foo}b') == 3 + assert r.smembers('{foo}c') == {b'1', b'2', b'3'} + r.sadd('{foo}b', '2', '3') + assert r.sdiffstore('{foo}c', '{foo}a', '{foo}b') == 1 + assert r.smembers('{foo}c') == {b'1'} + + def test_cluster_sinter(self, r): + r.sadd('{foo}a', '1', '2', '3') + assert r.sinter('{foo}a', '{foo}b') == set() + r.sadd('{foo}b', '2', '3') + assert r.sinter('{foo}a', '{foo}b') == {b'2', b'3'} + + def test_cluster_sinterstore(self, r): + r.sadd('{foo}a', '1', '2', '3') + assert r.sinterstore('{foo}c', '{foo}a', '{foo}b') == 0 + assert r.smembers('{foo}c') == set() + r.sadd('{foo}b', '2', '3') + assert r.sinterstore('{foo}c', '{foo}a', '{foo}b') == 2 + assert r.smembers('{foo}c') == {b'2', b'3'} + + def test_cluster_smove(self, r): + r.sadd('{foo}a', 'a1', 'a2') + r.sadd('{foo}b', 'b1', 'b2') + assert r.smove('{foo}a', '{foo}b', 'a1') + assert r.smembers('{foo}a') == {b'a2'} + assert r.smembers('{foo}b') == {b'b1', b'b2', b'a1'} + + def test_cluster_sunion(self, r): + r.sadd('{foo}a', '1', '2') + r.sadd('{foo}b', '2', '3') + assert r.sunion('{foo}a', '{foo}b') == {b'1', b'2', b'3'} + + def test_cluster_sunionstore(self, r): + r.sadd('{foo}a', '1', '2') + r.sadd('{foo}b', '2', '3') + assert r.sunionstore('{foo}c', '{foo}a', '{foo}b') == 3 + assert r.smembers('{foo}c') == {b'1', b'2', b'3'} + + @skip_if_server_version_lt('6.2.0') + def test_cluster_zdiff(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) + r.zadd('{foo}b', {'a1': 1, 'a2': 2}) + assert r.zdiff(['{foo}a', '{foo}b']) == [b'a3'] + assert r.zdiff(['{foo}a', '{foo}b'], withscores=True) == [b'a3', b'3'] + + @skip_if_server_version_lt('6.2.0') + def test_cluster_zdiffstore(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) + r.zadd('{foo}b', {'a1': 1, 'a2': 2}) + assert r.zdiffstore("{foo}out", ['{foo}a', '{foo}b']) + assert r.zrange("{foo}out", 0, -1) == [b'a3'] + assert r.zrange("{foo}out", 0, -1, withscores=True) == [(b'a3', 3.0)] + + @skip_if_server_version_lt('6.2.0') + def test_cluster_zinter(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 1}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zinter(['{foo}a', '{foo}b', '{foo}c']) == [b'a3', b'a1'] + # invalid aggregation + with pytest.raises(DataError): + r.zinter(['{foo}a', '{foo}b', '{foo}c'], + aggregate='foo', withscores=True) + # aggregate with SUM + assert r.zinter(['{foo}a', '{foo}b', '{foo}c'], withscores=True) \ + == [(b'a3', 8), (b'a1', 9)] + # aggregate with MAX + assert r.zinter(['{foo}a', '{foo}b', '{foo}c'], aggregate='MAX', + withscores=True) \ + == [(b'a3', 5), (b'a1', 6)] + # aggregate with MIN + assert r.zinter(['{foo}a', '{foo}b', '{foo}c'], aggregate='MIN', + withscores=True) \ + == [(b'a1', 1), (b'a3', 1)] + # with weights + assert r.zinter({'{foo}a': 1, '{foo}b': 2, '{foo}c': 3}, + withscores=True) \ + == [(b'a3', 20), (b'a1', 23)] + + def test_cluster_zinterstore_sum(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zinterstore('{foo}d', ['{foo}a', '{foo}b', '{foo}c']) == 2 + assert r.zrange('{foo}d', 0, -1, withscores=True) == \ + [(b'a3', 8), (b'a1', 9)] + + def test_cluster_zinterstore_max(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zinterstore( + '{foo}d', ['{foo}a', '{foo}b', '{foo}c'], aggregate='MAX') == 2 + assert r.zrange('{foo}d', 0, -1, withscores=True) == \ + [(b'a3', 5), (b'a1', 6)] + + def test_cluster_zinterstore_min(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) + r.zadd('{foo}b', {'a1': 2, 'a2': 3, 'a3': 5}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zinterstore( + '{foo}d', ['{foo}a', '{foo}b', '{foo}c'], aggregate='MIN') == 2 + assert r.zrange('{foo}d', 0, -1, withscores=True) == \ + [(b'a1', 1), (b'a3', 3)] + + def test_cluster_zinterstore_with_weight(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zinterstore( + '{foo}d', {'{foo}a': 1, '{foo}b': 2, '{foo}c': 3}) == 2 + assert r.zrange('{foo}d', 0, -1, withscores=True) == \ + [(b'a3', 20), (b'a1', 23)] + + @skip_if_server_version_lt('4.9.0') + def test_cluster_bzpopmax(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 2}) + r.zadd('{foo}b', {'b1': 10, 'b2': 20}) + assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) == ( + b'{foo}b', b'b2', 20) + assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) == ( + b'{foo}b', b'b1', 10) + assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) == ( + b'{foo}a', b'a2', 2) + assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) == ( + b'{foo}a', b'a1', 1) + assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) is None + r.zadd('{foo}c', {'c1': 100}) + assert r.bzpopmax('{foo}c', timeout=1) == (b'{foo}c', b'c1', 100) + + @skip_if_server_version_lt('4.9.0') + def test_cluster_bzpopmin(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 2}) + r.zadd('{foo}b', {'b1': 10, 'b2': 20}) + assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) == ( + b'{foo}b', b'b1', 10) + assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) == ( + b'{foo}b', b'b2', 20) + assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) == ( + b'{foo}a', b'a1', 1) + assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) == ( + b'{foo}a', b'a2', 2) + assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) is None + r.zadd('{foo}c', {'c1': 100}) + assert r.bzpopmin('{foo}c', timeout=1) == (b'{foo}c', b'c1', 100) + + @skip_if_server_version_lt('6.2.0') + def test_cluster_zrangestore(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) + assert r.zrangestore('{foo}b', '{foo}a', 0, 1) + assert r.zrange('{foo}b', 0, -1) == [b'a1', b'a2'] + assert r.zrangestore('{foo}b', '{foo}a', 1, 2) + assert r.zrange('{foo}b', 0, -1) == [b'a2', b'a3'] + assert r.zrange('{foo}b', 0, -1, withscores=True) == \ + [(b'a2', 2), (b'a3', 3)] + # reversed order + assert r.zrangestore('{foo}b', '{foo}a', 1, 2, desc=True) + assert r.zrange('{foo}b', 0, -1) == [b'a1', b'a2'] + # by score + assert r.zrangestore('{foo}b', '{foo}a', 2, 1, byscore=True, + offset=0, num=1, desc=True) + assert r.zrange('{foo}b', 0, -1) == [b'a2'] + # by lex + assert r.zrangestore('{foo}b', '{foo}a', '[a2', '(a3', bylex=True, + offset=0, num=1) + assert r.zrange('{foo}b', 0, -1) == [b'a2'] + + @skip_if_server_version_lt('6.2.0') + def test_cluster_zunion(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + # sum + assert r.zunion(['{foo}a', '{foo}b', '{foo}c']) == \ + [b'a2', b'a4', b'a3', b'a1'] + assert r.zunion(['{foo}a', '{foo}b', '{foo}c'], withscores=True) == \ + [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + # max + assert r.zunion(['{foo}a', '{foo}b', '{foo}c'], aggregate='MAX', + withscores=True) \ + == [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + # min + assert r.zunion(['{foo}a', '{foo}b', '{foo}c'], aggregate='MIN', + withscores=True) \ + == [(b'a1', 1), (b'a2', 1), (b'a3', 1), (b'a4', 4)] + # with weight + assert r.zunion({'{foo}a': 1, '{foo}b': 2, '{foo}c': 3}, + withscores=True) \ + == [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] + + def test_cluster_zunionstore_sum(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zunionstore('{foo}d', ['{foo}a', '{foo}b', '{foo}c']) == 4 + assert r.zrange('{foo}d', 0, -1, withscores=True) == \ + [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + + def test_cluster_zunionstore_max(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zunionstore( + '{foo}d', ['{foo}a', '{foo}b', '{foo}c'], aggregate='MAX') == 4 + assert r.zrange('{foo}d', 0, -1, withscores=True) == \ + [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + + def test_cluster_zunionstore_min(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 4}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zunionstore( + '{foo}d', ['{foo}a', '{foo}b', '{foo}c'], aggregate='MIN') == 4 + assert r.zrange('{foo}d', 0, -1, withscores=True) == \ + [(b'a1', 1), (b'a2', 2), (b'a3', 3), (b'a4', 4)] + + def test_cluster_zunionstore_with_weight(self, r): + r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) + r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) + r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + assert r.zunionstore( + '{foo}d', {'{foo}a': 1, '{foo}b': 2, '{foo}c': 3}) == 4 + assert r.zrange('{foo}d', 0, -1, withscores=True) == \ + [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] + + @skip_if_server_version_lt('2.8.9') + def test_cluster_pfcount(self, r): + members = {b'1', b'2', b'3'} + r.pfadd('{foo}a', *members) + assert r.pfcount('{foo}a') == len(members) + members_b = {b'2', b'3', b'4'} + r.pfadd('{foo}b', *members_b) + assert r.pfcount('{foo}b') == len(members_b) + assert r.pfcount('{foo}a', '{foo}b') == len(members_b.union(members)) + + @skip_if_server_version_lt('2.8.9') + def test_cluster_pfmerge(self, r): + mema = {b'1', b'2', b'3'} + memb = {b'2', b'3', b'4'} + memc = {b'5', b'6', b'7'} + r.pfadd('{foo}a', *mema) + r.pfadd('{foo}b', *memb) + r.pfadd('{foo}c', *memc) + r.pfmerge('{foo}d', '{foo}c', '{foo}a') + assert r.pfcount('{foo}d') == 6 + r.pfmerge('{foo}d', '{foo}b') + assert r.pfcount('{foo}d') == 7 + + def test_cluster_sort_store(self, r): + r.rpush('{foo}a', '2', '3', '1') + assert r.sort('{foo}a', store='{foo}sorted_values') == 3 + assert r.lrange('{foo}sorted_values', 0, -1) == [b'1', b'2', b'3'] + + # GEO COMMANDS + @skip_if_server_version_lt('6.2.0') + def test_cluster_geosearchstore(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + + r.geoadd('{foo}barcelona', values) + r.geosearchstore('{foo}places_barcelona', '{foo}barcelona', + longitude=2.191, latitude=41.433, radius=1000) + assert r.zrange('{foo}places_barcelona', 0, -1) == [b'place1'] + + @skip_unless_arch_bits(64) + @skip_if_server_version_lt('6.2.0') + def test_geosearchstore_dist(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + + r.geoadd('{foo}barcelona', values) + r.geosearchstore('{foo}places_barcelona', '{foo}barcelona', + longitude=2.191, latitude=41.433, + radius=1000, storedist=True) + # instead of save the geo score, the distance is saved. + assert r.zscore('{foo}places_barcelona', 'place1') == 88.05060698409301 + + @skip_if_server_version_lt('3.2.0') + def test_cluster_georadius_store(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + + r.geoadd('{foo}barcelona', values) + r.georadius('{foo}barcelona', 2.191, 41.433, + 1000, store='{foo}places_barcelona') + assert r.zrange('{foo}places_barcelona', 0, -1) == [b'place1'] + + @skip_unless_arch_bits(64) + @skip_if_server_version_lt('3.2.0') + def test_cluster_georadius_store_dist(self, r): + values = (2.1909389952632, 41.433791470673, 'place1') + \ + (2.1873744593677, 41.406342043777, 'place2') + + r.geoadd('{foo}barcelona', values) + r.georadius('{foo}barcelona', 2.191, 41.433, 1000, + store_dist='{foo}places_barcelona') + # instead of save the geo score, the distance is saved. + assert r.zscore('{foo}places_barcelona', 'place1') == 88.05060698409301 + + def test_cluster_dbsize(self, r): + d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + assert r.mset_nonatomic(d) + assert r.dbsize(target_nodes='primaries') == len(d) + + def test_cluster_keys(self, r): + assert r.keys() == [] + keys_with_underscores = {b'test_a', b'test_b'} + keys = keys_with_underscores.union({b'testc'}) + for key in keys: + r[key] = 1 + assert set(r.keys(pattern='test_*', target_nodes='primaries')) == \ + keys_with_underscores + assert set(r.keys(pattern='test*', target_nodes='primaries')) == keys + + # SCAN COMMANDS + @skip_if_server_version_lt('2.8.0') + def test_cluster_scan(self, r): + r.set('a', 1) + r.set('b', 2) + r.set('c', 3) + cursor, keys = r.scan(target_nodes='primaries') + assert cursor == 0 + assert set(keys) == {b'a', b'b', b'c'} + _, keys = r.scan(match='a', target_nodes='primaries') + assert set(keys) == {b'a'} + + @skip_if_server_version_lt("6.0.0") + def test_cluster_scan_type(self, r): + r.sadd('a-set', 1) + r.hset('a-hash', 'foo', 2) + r.lpush('a-list', 'aux', 3) + _, keys = r.scan(match='a*', _type='SET', target_nodes='primaries') + assert set(keys) == {b'a-set'} + + @skip_if_server_version_lt('2.8.0') + def test_cluster_scan_iter(self, r): + r.set('a', 1) + r.set('b', 2) + r.set('c', 3) + keys = list(r.scan_iter(target_nodes='primaries')) + assert set(keys) == {b'a', b'b', b'c'} + keys = list(r.scan_iter(match='a', target_nodes='primaries')) + assert set(keys) == {b'a'} + + def test_cluster_randomkey(self, r): + node = r.get_node_from_key('{foo}') + assert r.randomkey(target_nodes=node) is None + for key in ('{foo}a', '{foo}b', '{foo}c'): + r[key] = 1 + assert r.randomkey(target_nodes=node) in \ + (b'{foo}a', b'{foo}b', b'{foo}c') + + +@pytest.mark.onlycluster +class TestNodesManager: + """ + Tests for the NodesManager class + """ + + def test_load_balancer(self, r): + n_manager = r.nodes_manager + lb = n_manager.read_load_balancer + slot_1 = 1257 + slot_2 = 8975 + node_1 = ClusterNode(default_host, 6379, PRIMARY) + node_2 = ClusterNode(default_host, 6378, REPLICA) + node_3 = ClusterNode(default_host, 6377, REPLICA) + node_4 = ClusterNode(default_host, 6376, PRIMARY) + node_5 = ClusterNode(default_host, 6375, REPLICA) + n_manager.slots_cache = { + slot_1: [node_1, node_2, node_3], + slot_2: [node_4, node_5] + } + primary1_name = n_manager.slots_cache[slot_1][0].name + primary2_name = n_manager.slots_cache[slot_2][0].name + list1_size = len(n_manager.slots_cache[slot_1]) + list2_size = len(n_manager.slots_cache[slot_2]) + # slot 1 + assert lb.get_server_index(primary1_name, list1_size) == 0 + assert lb.get_server_index(primary1_name, list1_size) == 1 + assert lb.get_server_index(primary1_name, list1_size) == 2 + assert lb.get_server_index(primary1_name, list1_size) == 0 + # slot 2 + assert lb.get_server_index(primary2_name, list2_size) == 0 + assert lb.get_server_index(primary2_name, list2_size) == 1 + assert lb.get_server_index(primary2_name, list2_size) == 0 + + lb.reset() + assert lb.get_server_index(primary1_name, list1_size) == 0 + assert lb.get_server_index(primary2_name, list2_size) == 0 + + def test_init_slots_cache_not_all_slots_covered(self): + """ + Test that if not all slots are covered it should raise an exception + """ + # Missing slot 5460 + cluster_slots = [ + [0, 5459, ['127.0.0.1', 7000], ['127.0.0.1', 7003]], + [5461, 10922, ['127.0.0.1', 7001], + ['127.0.0.1', 7004]], + [10923, 16383, ['127.0.0.1', 7002], + ['127.0.0.1', 7005]], + ] + with pytest.raises(RedisClusterException) as ex: + get_mocked_redis_client(host=default_host, port=default_port, + cluster_slots=cluster_slots) + assert str(ex.value).startswith( + "All slots are not covered after query all startup_nodes.") + + def test_init_slots_cache_not_require_full_coverage_error(self): + """ + When require_full_coverage is set to False and not all slots are + covered, if one of the nodes has 'cluster-require_full_coverage' + config set to 'yes' the cluster initialization should fail + """ + # Missing slot 5460 + cluster_slots = [ + [0, 5459, ['127.0.0.1', 7000], ['127.0.0.1', 7003]], + [5461, 10922, ['127.0.0.1', 7001], + ['127.0.0.1', 7004]], + [10923, 16383, ['127.0.0.1', 7002], + ['127.0.0.1', 7005]], + ] + + with pytest.raises(RedisClusterException): + get_mocked_redis_client(host=default_host, port=default_port, + cluster_slots=cluster_slots, + require_full_coverage=False, + coverage_result='yes') + + def test_init_slots_cache_not_require_full_coverage_success(self): + """ + When require_full_coverage is set to False and not all slots are + covered, if all of the nodes has 'cluster-require_full_coverage' + config set to 'no' the cluster initialization should succeed + """ + # Missing slot 5460 + cluster_slots = [ + [0, 5459, ['127.0.0.1', 7000], ['127.0.0.1', 7003]], + [5461, 10922, ['127.0.0.1', 7001], + ['127.0.0.1', 7004]], + [10923, 16383, ['127.0.0.1', 7002], + ['127.0.0.1', 7005]], + ] + + rc = get_mocked_redis_client(host=default_host, port=default_port, + cluster_slots=cluster_slots, + require_full_coverage=False, + coverage_result='no') + + assert 5460 not in rc.nodes_manager.slots_cache + + def test_init_slots_cache_not_require_full_coverage_skips_check(self): + """ + Test that when require_full_coverage is set to False and + skip_full_coverage_check is set to true, the cluster initialization + succeed without checking the nodes' Redis configurations + """ + # Missing slot 5460 + cluster_slots = [ + [0, 5459, ['127.0.0.1', 7000], ['127.0.0.1', 7003]], + [5461, 10922, ['127.0.0.1', 7001], + ['127.0.0.1', 7004]], + [10923, 16383, ['127.0.0.1', 7002], + ['127.0.0.1', 7005]], + ] + + with patch.object(NodesManager, + 'cluster_require_full_coverage') as conf_check_mock: + rc = get_mocked_redis_client(host=default_host, port=default_port, + cluster_slots=cluster_slots, + require_full_coverage=False, + skip_full_coverage_check=True, + coverage_result='no') + + assert conf_check_mock.called is False + assert 5460 not in rc.nodes_manager.slots_cache + + def test_init_slots_cache(self): + """ + Test that slots cache can in initialized and all slots are covered + """ + good_slots_resp = [ + [0, 5460, ['127.0.0.1', 7000], ['127.0.0.2', 7003]], + [5461, 10922, ['127.0.0.1', 7001], ['127.0.0.2', 7004]], + [10923, 16383, ['127.0.0.1', 7002], ['127.0.0.2', 7005]], + ] + + rc = get_mocked_redis_client(host=default_host, port=default_port, + cluster_slots=good_slots_resp) + n_manager = rc.nodes_manager + assert len(n_manager.slots_cache) == REDIS_CLUSTER_HASH_SLOTS + for slot_info in good_slots_resp: + all_hosts = ['127.0.0.1', '127.0.0.2'] + all_ports = [7000, 7001, 7002, 7003, 7004, 7005] + slot_start = slot_info[0] + slot_end = slot_info[1] + for i in range(slot_start, slot_end + 1): + assert len(n_manager.slots_cache[i]) == len(slot_info[2:]) + assert n_manager.slots_cache[i][0].host in all_hosts + assert n_manager.slots_cache[i][1].host in all_hosts + assert n_manager.slots_cache[i][0].port in all_ports + assert n_manager.slots_cache[i][1].port in all_ports + + assert len(n_manager.nodes_cache) == 6 + + def test_empty_startup_nodes(self): + """ + It should not be possible to create a node manager with no nodes + specified + """ + with pytest.raises(RedisClusterException): + NodesManager([]) + + def test_wrong_startup_nodes_type(self): + """ + If something other then a list type itteratable is provided it should + fail + """ + with pytest.raises(RedisClusterException): + NodesManager({}) + + def test_init_slots_cache_slots_collision(self, request): + """ + Test that if 2 nodes do not agree on the same slots setup it should + raise an error. In this test both nodes will say that the first + slots block should be bound to different servers. + """ + with patch.object(NodesManager, + 'create_redis_node') as create_redis_node: + def create_mocked_redis_node(host, port, **kwargs): + """ + Helper function to return custom slots cache data from + different redis nodes + """ + if port == 7000: + result = [ + [ + 0, + 5460, + ['127.0.0.1', 7000], + ['127.0.0.1', 7003], + ], + [ + 5461, + 10922, + ['127.0.0.1', 7001], + ['127.0.0.1', 7004], + ], + ] + + elif port == 7001: + result = [ + [ + 0, + 5460, + ['127.0.0.1', 7001], + ['127.0.0.1', 7003], + ], + [ + 5461, + 10922, + ['127.0.0.1', 7000], + ['127.0.0.1', 7004], + ], + ] + else: + result = [] + + r_node = Redis( + host=host, + port=port + ) + + orig_execute_command = r_node.execute_command + + def execute_command(*args, **kwargs): + if args[0] == 'CLUSTER SLOTS': + return result + elif args[1] == 'cluster-require-full-coverage': + return {'cluster-require-full-coverage': 'yes'} + else: + return orig_execute_command(*args, **kwargs) + + r_node.execute_command = execute_command + return r_node + + create_redis_node.side_effect = create_mocked_redis_node + + with pytest.raises(RedisClusterException) as ex: + node_1 = ClusterNode('127.0.0.1', 7000) + node_2 = ClusterNode('127.0.0.1', 7001) + RedisCluster(startup_nodes=[node_1, node_2]) + assert str(ex.value).startswith( + "startup_nodes could not agree on a valid slots cache"), str( + ex.value) + + def test_cluster_one_instance(self): + """ + If the cluster exists of only 1 node then there is some hacks that must + be validated they work. + """ + node = ClusterNode(default_host, default_port) + cluster_slots = [[0, 16383, ['', default_port]]] + rc = get_mocked_redis_client(startup_nodes=[node], + cluster_slots=cluster_slots) + + n = rc.nodes_manager + assert len(n.nodes_cache) == 1 + n_node = rc.get_node(node_name=node.name) + assert n_node is not None + assert n_node == node + assert n_node.server_type == PRIMARY + assert len(n.slots_cache) == REDIS_CLUSTER_HASH_SLOTS + for i in range(0, REDIS_CLUSTER_HASH_SLOTS): + assert n.slots_cache[i] == [n_node] + + def test_init_with_down_node(self): + """ + If I can't connect to one of the nodes, everything should still work. + But if I can't connect to any of the nodes, exception should be thrown. + """ + with patch.object(NodesManager, + 'create_redis_node') as create_redis_node: + def create_mocked_redis_node(host, port, **kwargs): + if port == 7000: + raise ConnectionError('mock connection error for 7000') + + r_node = Redis(host=host, port=port, decode_responses=True) + + def execute_command(*args, **kwargs): + if args[0] == 'CLUSTER SLOTS': + return [ + [ + 0, 8191, + ['127.0.0.1', 7001, 'node_1'], + ], + [ + 8192, 16383, + ['127.0.0.1', 7002, 'node_2'], + ] + ] + elif args[1] == 'cluster-require-full-coverage': + return {'cluster-require-full-coverage': 'yes'} + + r_node.execute_command = execute_command + + return r_node + + create_redis_node.side_effect = create_mocked_redis_node + + node_1 = ClusterNode('127.0.0.1', 7000) + node_2 = ClusterNode('127.0.0.1', 7001) + + # If all startup nodes fail to connect, connection error should be + # thrown + with pytest.raises(RedisClusterException) as e: + RedisCluster(startup_nodes=[node_1]) + assert 'Redis Cluster cannot be connected' in str(e.value) + + with patch.object(CommandsParser, 'initialize', + autospec=True) as cmd_parser_initialize: + + def cmd_init_mock(self, r): + self.commands = {'get': {'name': 'get', 'arity': 2, + 'flags': ['readonly', + 'fast'], + 'first_key_pos': 1, + 'last_key_pos': 1, + 'step_count': 1}} + + cmd_parser_initialize.side_effect = cmd_init_mock + # When at least one startup node is reachable, the cluster + # initialization should succeeds + rc = RedisCluster(startup_nodes=[node_1, node_2]) + assert rc.get_node(host=default_host, port=7001) is not None + assert rc.get_node(host=default_host, port=7002) is not None + + +@pytest.mark.onlycluster +class TestClusterPubSubObject: + """ + Tests for the ClusterPubSub class + """ + + def test_init_pubsub_with_host_and_port(self, r): + """ + Test creation of pubsub instance with passed host and port + """ + node = r.get_default_node() + p = r.pubsub(host=node.host, port=node.port) + assert p.get_pubsub_node() == node + + def test_init_pubsub_with_node(self, r): + """ + Test creation of pubsub instance with passed node + """ + node = r.get_default_node() + p = r.pubsub(node=node) + assert p.get_pubsub_node() == node + + def test_init_pubusub_without_specifying_node(self, r): + """ + Test creation of pubsub instance without specifying a node. The node + should be determined based on the keyslot of the first command + execution. + """ + channel_name = 'foo' + node = r.get_node_from_key(channel_name) + p = r.pubsub() + assert p.get_pubsub_node() is None + p.subscribe(channel_name) + assert p.get_pubsub_node() == node + + def test_init_pubsub_with_a_non_existent_node(self, r): + """ + Test creation of pubsub instance with node that doesn't exists in the + cluster. RedisClusterException should be raised. + """ + node = ClusterNode('1.1.1.1', 1111) + with pytest.raises(RedisClusterException): + r.pubsub(node) + + def test_init_pubsub_with_a_non_existent_host_port(self, r): + """ + Test creation of pubsub instance with host and port that don't belong + to a node in the cluster. + RedisClusterException should be raised. + """ + with pytest.raises(RedisClusterException): + r.pubsub(host='1.1.1.1', port=1111) + + def test_init_pubsub_host_or_port(self, r): + """ + Test creation of pubsub instance with host but without port, and vice + versa. DataError should be raised. + """ + with pytest.raises(DataError): + r.pubsub(host='localhost') + + with pytest.raises(DataError): + r.pubsub(port=16379) + + def test_get_redis_connection(self, r): + """ + Test that get_redis_connection() returns the redis connection of the + set pubsub node + """ + node = r.get_default_node() + p = r.pubsub(node=node) + assert p.get_redis_connection() == node.redis_connection + + +@pytest.mark.onlycluster +class TestClusterPipeline: + """ + Tests for the ClusterPipeline class + """ + + def test_blocked_methods(self, r): + """ + Currently some method calls on a Cluster pipeline + is blocked when using in cluster mode. + They maybe implemented in the future. + """ + pipe = r.pipeline() + with pytest.raises(RedisClusterException): + pipe.multi() + + with pytest.raises(RedisClusterException): + pipe.immediate_execute_command() + + with pytest.raises(RedisClusterException): + pipe._execute_transaction(None, None, None) + + with pytest.raises(RedisClusterException): + pipe.load_scripts() + + with pytest.raises(RedisClusterException): + pipe.watch() + + with pytest.raises(RedisClusterException): + pipe.unwatch() + + with pytest.raises(RedisClusterException): + pipe.script_load_for_pipeline(None) + + with pytest.raises(RedisClusterException): + pipe.eval() + + def test_blocked_arguments(self, r): + """ + Currently some arguments is blocked when using in cluster mode. + They maybe implemented in the future. + """ + with pytest.raises(RedisClusterException) as ex: + r.pipeline(transaction=True) + + assert str(ex.value).startswith( + "transaction is deprecated in cluster mode") is True + + with pytest.raises(RedisClusterException) as ex: + r.pipeline(shard_hint=True) + + assert str(ex.value).startswith( + "shard_hint is deprecated in cluster mode") is True + + def test_redis_cluster_pipeline(self, r): + """ + Test that we can use a pipeline with the RedisCluster class + """ + with r.pipeline() as pipe: + pipe.set("foo", "bar") + pipe.get("foo") + assert pipe.execute() == [True, b'bar'] + + def test_mget_disabled(self, r): + """ + Test that mget is disabled for ClusterPipeline + """ + with r.pipeline() as pipe: + with pytest.raises(RedisClusterException): + pipe.mget(['a']) + + def test_mset_disabled(self, r): + """ + Test that mset is disabled for ClusterPipeline + """ + with r.pipeline() as pipe: + with pytest.raises(RedisClusterException): + pipe.mset({'a': 1, 'b': 2}) + + def test_rename_disabled(self, r): + """ + Test that rename is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.rename('a', 'b') + + def test_renamenx_disabled(self, r): + """ + Test that renamenx is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.renamenx('a', 'b') + + def test_delete_single(self, r): + """ + Test a single delete operation + """ + r['a'] = 1 + with r.pipeline(transaction=False) as pipe: + pipe.delete('a') + assert pipe.execute() == [1] + + def test_multi_delete_unsupported(self, r): + """ + Test that multi delete operation is unsupported + """ + with r.pipeline(transaction=False) as pipe: + r['a'] = 1 + r['b'] = 2 + with pytest.raises(RedisClusterException): + pipe.delete('a', 'b') + + def test_brpoplpush_disabled(self, r): + """ + Test that brpoplpush is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.brpoplpush() + + def test_rpoplpush_disabled(self, r): + """ + Test that rpoplpush is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.rpoplpush() + + def test_sort_disabled(self, r): + """ + Test that sort is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.sort() + + def test_sdiff_disabled(self, r): + """ + Test that sdiff is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.sdiff() + + def test_sdiffstore_disabled(self, r): + """ + Test that sdiffstore is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.sdiffstore() + + def test_sinter_disabled(self, r): + """ + Test that sinter is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.sinter() + + def test_sinterstore_disabled(self, r): + """ + Test that sinterstore is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.sinterstore() + + def test_smove_disabled(self, r): + """ + Test that move is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.smove() + + def test_sunion_disabled(self, r): + """ + Test that sunion is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.sunion() + + def test_sunionstore_disabled(self, r): + """ + Test that sunionstore is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.sunionstore() + + def test_spfmerge_disabled(self, r): + """ + Test that spfmerge is disabled for ClusterPipeline + """ + with r.pipeline(transaction=False) as pipe: + with pytest.raises(RedisClusterException): + pipe.pfmerge() + + def test_multi_key_operation_with_a_single_slot(self, r): + """ + Test multi key operation with a single slot + """ + pipe = r.pipeline(transaction=False) + pipe.set('a{foo}', 1) + pipe.set('b{foo}', 2) + pipe.set('c{foo}', 3) + pipe.get('a{foo}') + pipe.get('b{foo}') + pipe.get('c{foo}') + + res = pipe.execute() + assert res == [True, True, True, b'1', b'2', b'3'] + + def test_multi_key_operation_with_multi_slots(self, r): + """ + Test multi key operation with more than one slot + """ + pipe = r.pipeline(transaction=False) + pipe.set('a{foo}', 1) + pipe.set('b{foo}', 2) + pipe.set('c{foo}', 3) + pipe.set('bar', 4) + pipe.set('bazz', 5) + pipe.get('a{foo}') + pipe.get('b{foo}') + pipe.get('c{foo}') + pipe.get('bar') + pipe.get('bazz') + res = pipe.execute() + assert res == [True, True, True, True, True, b'1', b'2', b'3', b'4', + b'5'] + + def test_connection_error_not_raised(self, r): + """ + Test that the pipeline doesn't raise an error on connection error when + raise_on_error=False + """ + key = 'foo' + node = r.get_node_from_key(key, False) + + def raise_connection_error(): + e = ConnectionError("error") + return e + + with r.pipeline() as pipe: + mock_node_resp_func(node, raise_connection_error) + res = pipe.get(key).get(key).execute(raise_on_error=False) + assert node.redis_connection.connection.read_response.called + assert isinstance(res[0], ConnectionError) + + def test_connection_error_raised(self, r): + """ + Test that the pipeline raises an error on connection error when + raise_on_error=True + """ + key = 'foo' + node = r.get_node_from_key(key, False) + + def raise_connection_error(): + e = ConnectionError("error") + return e + + with r.pipeline() as pipe: + mock_node_resp_func(node, raise_connection_error) + with pytest.raises(ConnectionError): + pipe.get(key).get(key).execute(raise_on_error=True) + + def test_asking_error(self, r): + """ + Test redirection on ASK error + """ + key = 'foo' + first_node = r.get_node_from_key(key, False) + ask_node = None + for node in r.get_nodes(): + if node != first_node: + ask_node = node + break + if ask_node is None: + warnings.warn("skipping this test since the cluster has only one " + "node") + return + ask_msg = "{0} {1}:{2}".format(r.keyslot(key), ask_node.host, + ask_node.port) + + def raise_ask_error(): + raise AskError(ask_msg) + + with r.pipeline() as pipe: + mock_node_resp_func(first_node, raise_ask_error) + mock_node_resp(ask_node, 'MOCK_OK') + res = pipe.get(key).execute() + assert first_node.redis_connection.connection.read_response.called + assert ask_node.redis_connection.connection.read_response.called + assert res == ['MOCK_OK'] + + def test_empty_stack(self, r): + """ + If pipeline is executed with no commands it should + return a empty list. + """ + p = r.pipeline() + result = p.execute() + assert result == [] + + +@pytest.mark.onlycluster +class TestReadOnlyPipeline: + """ + Tests for ClusterPipeline class in readonly mode + """ + + def test_pipeline_readonly(self, r): + """ + On readonly mode, we supports get related stuff only. + """ + r.readonly(target_nodes='all') + r.set('foo71', 'a1') # we assume this key is set on 127.0.0.1:7001 + r.zadd('foo88', + {'z1': 1}) # we assume this key is set on 127.0.0.1:7002 + r.zadd('foo88', {'z2': 4}) + + with r.pipeline() as readonly_pipe: + readonly_pipe.get('foo71').zrange('foo88', 0, 5, withscores=True) + assert readonly_pipe.execute() == [ + b'a1', + [(b'z1', 1.0), (b'z2', 4)], + ] + + def test_moved_redirection_on_slave_with_default(self, r): + """ + On Pipeline, we redirected once and finally get from master with + readonly client when data is completely moved. + """ + key = 'bar' + r.set(key, 'foo') + # set read_from_replicas to True + r.read_from_replicas = True + primary = r.get_node_from_key(key, False) + replica = r.get_node_from_key(key, True) + with r.pipeline() as readwrite_pipe: + mock_node_resp(primary, "MOCK_FOO") + if replica is not None: + moved_error = "{0} {1}:{2}".format(r.keyslot(key), + primary.host, + primary.port) + + def raise_moved_error(): + raise MovedError(moved_error) + + mock_node_resp_func(replica, raise_moved_error) + assert readwrite_pipe.reinitialize_counter == 0 + readwrite_pipe.get(key).get(key) + assert readwrite_pipe.execute() == ["MOCK_FOO", "MOCK_FOO"] + if replica is not None: + # the slot has a replica as well, so MovedError should have + # occurred. If MovedError occurs, we should see the + # reinitialize_counter increase. + assert readwrite_pipe.reinitialize_counter == 1 + conn = replica.redis_connection.connection + assert conn.read_response.called is True + + def test_readonly_pipeline_from_readonly_client(self, request): + """ + Test that the pipeline is initialized with readonly mode if the client + has it enabled + """ + # Create a cluster with reading from replications + ro = _get_client(RedisCluster, request, read_from_replicas=True) + key = 'bar' + ro.set(key, 'foo') + import time + time.sleep(0.2) + with ro.pipeline() as readonly_pipe: + mock_all_nodes_resp(ro, 'MOCK_OK') + assert readonly_pipe.read_from_replicas is True + assert readonly_pipe.get(key).get( + key).execute() == ['MOCK_OK', 'MOCK_OK'] + slot_nodes = ro.nodes_manager.slots_cache[ro.keyslot(key)] + if len(slot_nodes) > 1: + executed_on_replica = False + for node in slot_nodes: + if node.server_type == REPLICA: + conn = node.redis_connection.connection + executed_on_replica = conn.read_response.called + if executed_on_replica: + break + assert executed_on_replica is True diff --git a/tests/test_command_parser.py b/tests/test_command_parser.py new file mode 100644 index 0000000000..ba129ba673 --- /dev/null +++ b/tests/test_command_parser.py @@ -0,0 +1,62 @@ +import pytest + +from redis.commands import CommandsParser + + +class TestCommandsParser: + def test_init_commands(self, r): + commands_parser = CommandsParser(r) + assert commands_parser.commands is not None + assert 'get' in commands_parser.commands + + def test_get_keys_predetermined_key_location(self, r): + commands_parser = CommandsParser(r) + args1 = ['GET', 'foo'] + args2 = ['OBJECT', 'encoding', 'foo'] + args3 = ['MGET', 'foo', 'bar', 'foobar'] + assert commands_parser.get_keys(r, *args1) == ['foo'] + assert commands_parser.get_keys(r, *args2) == ['foo'] + assert commands_parser.get_keys(r, *args3) == ['foo', 'bar', 'foobar'] + + @pytest.mark.filterwarnings("ignore:ResponseError") + def test_get_moveable_keys(self, r): + commands_parser = CommandsParser(r) + args1 = ['EVAL', 'return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}', 2, 'key1', + 'key2', 'first', 'second'] + args2 = ['XREAD', 'COUNT', 2, b'STREAMS', 'mystream', 'writers', 0, 0] + args3 = ['ZUNIONSTORE', 'out', 2, 'zset1', 'zset2', 'WEIGHTS', 2, 3] + args4 = ['GEORADIUS', 'Sicily', 15, 37, 200, 'km', 'WITHCOORD', + b'STORE', 'out'] + args5 = ['MEMORY USAGE', 'foo'] + args6 = ['MIGRATE', '192.168.1.34', 6379, "", 0, 5000, b'KEYS', + 'key1', 'key2', 'key3'] + args7 = ['MIGRATE', '192.168.1.34', 6379, "key1", 0, 5000] + args8 = ['STRALGO', 'LCS', 'STRINGS', 'string_a', 'string_b'] + args9 = ['STRALGO', 'LCS', 'KEYS', 'key1', 'key2'] + + assert commands_parser.get_keys( + r, *args1).sort() == ['key1', 'key2'].sort() + assert commands_parser.get_keys( + r, *args2).sort() == ['mystream', 'writers'].sort() + assert commands_parser.get_keys( + r, *args3).sort() == ['out', 'zset1', 'zset2'].sort() + assert commands_parser.get_keys( + r, *args4).sort() == ['Sicily', 'out'].sort() + assert commands_parser.get_keys(r, *args5).sort() == ['foo'].sort() + assert commands_parser.get_keys( + r, *args6).sort() == ['key1', 'key2', 'key3'].sort() + assert commands_parser.get_keys(r, *args7).sort() == ['key1'].sort() + assert commands_parser.get_keys(r, *args8) is None + assert commands_parser.get_keys( + r, *args9).sort() == ['key1', 'key2'].sort() + + def test_get_pubsub_keys(self, r): + commands_parser = CommandsParser(r) + args1 = ['PUBLISH', 'foo', 'bar'] + args2 = ['PUBSUB NUMSUB', 'foo1', 'foo2', 'foo3'] + args3 = ['PUBSUB channels', '*'] + args4 = ['SUBSCRIBE', 'foo1', 'foo2', 'foo3'] + assert commands_parser.get_keys(r, *args1) == ['foo'] + assert commands_parser.get_keys(r, *args2) == ['foo1', 'foo2', 'foo3'] + assert commands_parser.get_keys(r, *args3) == ['*'] + assert commands_parser.get_keys(r, *args4) == ['foo1', 'foo2', 'foo3'] diff --git a/tests/test_commands.py b/tests/test_commands.py index dbd04429b4..780a1bf443 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -47,6 +47,7 @@ def get_stream_message(client, stream, message_id): # RESPONSE CALLBACKS +@pytest.mark.onlynoncluster class TestResponseCallbacks: "Tests for the response callback system" @@ -68,18 +69,21 @@ def test_command_on_invalid_key_type(self, r): r['a'] # SERVER INFORMATION + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_cat_no_category(self, r): categories = r.acl_cat() assert isinstance(categories, list) assert 'read' in categories + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_cat_with_category(self, r): commands = r.acl_cat('read') assert isinstance(commands, list) assert 'get' in commands + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_deluser(self, r, request): @@ -105,6 +109,7 @@ def teardown(): assert r.acl_getuser(users[3]) is None assert r.acl_getuser(users[4]) is None + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_genpass(self, r): @@ -119,6 +124,7 @@ def test_acl_genpass(self, r): r.acl_genpass(555) assert isinstance(password, str) + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_getuser_setuser(self, r, request): @@ -207,12 +213,14 @@ def teardown(): hashed_passwords=['-' + hashed_password]) assert len(r.acl_getuser(username)['passwords']) == 1 + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_help(self, r): res = r.acl_help() assert isinstance(res, list) assert len(res) != 0 + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_list(self, r, request): @@ -226,6 +234,7 @@ def teardown(): users = r.acl_list() assert len(users) == 2 + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_log(self, r, request): @@ -262,6 +271,7 @@ def teardown(): assert 'client-info' in r.acl_log(count=1)[0] assert r.acl_log_reset() + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_categories_without_prefix_fails(self, r, request): @@ -274,6 +284,7 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, categories=['list']) + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_commands_without_prefix_fails(self, r, request): @@ -286,6 +297,7 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, commands=['get']) + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request): @@ -298,28 +310,33 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, passwords='+mypass', nopass=True) + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_users(self, r): users = r.acl_users() assert isinstance(users, list) assert len(users) > 0 + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_whoami(self, r): username = r.acl_whoami() assert isinstance(username, str) + @pytest.mark.onlynoncluster def test_client_list(self, r): clients = r.client_list() assert isinstance(clients[0], dict) assert 'addr' in clients[0] + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_client_info(self, r): info = r.client_info() assert isinstance(info, dict) assert 'addr' in info + @pytest.mark.onlynoncluster @skip_if_server_version_lt('5.0.0') def test_client_list_types_not_replica(self, r): with pytest.raises(exceptions.RedisError): @@ -333,6 +350,7 @@ def test_client_list_replica(self, r): clients = r.client_list(_type='replica') assert isinstance(clients, list) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_client_list_client_id(self, r, request): clients = r.client_list() @@ -347,16 +365,19 @@ def test_client_list_client_id(self, r, request): clients_listed = r.client_list(client_id=clients[:-1]) assert len(clients_listed) > 1 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('5.0.0') def test_client_id(self, r): assert r.client_id() > 0 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_client_trackinginfo(self, r): res = r.client_trackinginfo() assert len(res) > 2 assert 'prefixes' in res + @pytest.mark.onlynoncluster @skip_if_server_version_lt('5.0.0') def test_client_unblock(self, r): myid = r.client_id() @@ -364,15 +385,18 @@ def test_client_unblock(self, r): assert not r.client_unblock(myid, error=True) assert not r.client_unblock(myid, error=False) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.6.9') def test_client_getname(self, r): assert r.client_getname() is None + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.6.9') def test_client_setname(self, r): assert r.client_setname('redis_py_test') assert r.client_getname() == 'redis_py_test' + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.6.9') def test_client_kill(self, r, r2): r.client_setname('redis-py-c1') @@ -406,6 +430,7 @@ def test_client_kill_filter_invalid_params(self, r): with pytest.raises(exceptions.DataError): r.client_kill_filter(_type="caster") + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.12') def test_client_kill_filter_by_id(self, r, r2): r.client_setname('redis-py-c1') @@ -426,6 +451,7 @@ def test_client_kill_filter_by_id(self, r, r2): assert len(clients) == 1 assert clients[0].get('name') == 'redis-py-c1' + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.12') def test_client_kill_filter_by_addr(self, r, r2): r.client_setname('redis-py-c1') @@ -481,6 +507,7 @@ def test_client_kill_filter_by_user(self, r, request): assert c['user'] != killuser r.acl_deluser(killuser) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.9.50') @skip_if_redis_enterprise def test_client_pause(self, r): @@ -489,11 +516,13 @@ def test_client_pause(self, r): with pytest.raises(exceptions.RedisError): r.client_pause(timeout='not an integer') + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') @skip_if_redis_enterprise def test_client_unpause(self, r): assert r.client_unpause() == b'OK' + @pytest.mark.onlynoncluster @skip_if_server_version_lt('3.2.0') def test_client_reply(self, r, r_timeout): assert r_timeout.client_reply('ON') == b'OK' @@ -507,6 +536,7 @@ def test_client_reply(self, r, r_timeout): # validate it was set assert r.get('foo') == b'bar' + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.0.0') @skip_if_redis_enterprise def test_client_getredir(self, r): @@ -519,6 +549,7 @@ def test_config_get(self, r): # # assert 'maxmemory' in data # assert data['maxmemory'].isdigit() + @pytest.mark.onlynoncluster @skip_if_redis_enterprise def test_config_resetstat(self, r): r.ping() @@ -535,14 +566,17 @@ def test_config_set(self, r): assert r.config_set('timeout', 0) assert r.config_get()['timeout'] == '0' + @pytest.mark.onlynoncluster def test_dbsize(self, r): r['a'] = 'foo' r['b'] = 'bar' assert r.dbsize() == 2 + @pytest.mark.onlynoncluster def test_echo(self, r): assert r.echo('foo bar') == b'foo bar' + @pytest.mark.onlynoncluster def test_info(self, r): r['a'] = 'foo' r['b'] = 'bar' @@ -551,10 +585,12 @@ def test_info(self, r): assert 'arch_bits' in info.keys() assert 'redis_version' in info.keys() + @pytest.mark.onlynoncluster @skip_if_redis_enterprise def test_lastsave(self, r): assert isinstance(r.lastsave(), datetime.datetime) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('5.0.0') def test_lolwut(self, r): lolwut = r.lolwut().decode('utf-8') @@ -573,9 +609,11 @@ def test_object(self, r): def test_ping(self, r): assert r.ping() + @pytest.mark.onlynoncluster def test_quit(self, r): assert r.quit() + @pytest.mark.onlynoncluster def test_slowlog_get(self, r, slowlog): assert r.slowlog_reset() unicode_string = chr(3456) + 'abcd' + chr(3421) @@ -613,6 +651,7 @@ def parse_response(connection, command_name, **options): # Complexity info stored as fourth item in list response.insert(3, COMPLEXITY_STATEMENT) return r.response_callbacks[command_name](responses, **options) + r.parse_response = parse_response # test @@ -626,6 +665,7 @@ def parse_response(connection, command_name, **options): # tear down monkeypatch r.parse_response = old_parse_response + @pytest.mark.onlynoncluster def test_slowlog_get_limit(self, r, slowlog): assert r.slowlog_reset() r.get('foo') @@ -634,6 +674,7 @@ def test_slowlog_get_limit(self, r, slowlog): # only one command, based on the number we passed to slowlog_get() assert len(slowlog) == 1 + @pytest.mark.onlynoncluster def test_slowlog_length(self, r, slowlog): r.get('foo') assert isinstance(r.slowlog_len(), int) @@ -677,12 +718,14 @@ def test_bitcount(self, r): assert r.bitcount('a', -2, -1) == 2 assert r.bitcount('a', 1, 1) == 1 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.6.0') def test_bitop_not_empty_string(self, r): r['a'] = '' r.bitop('not', 'r', 'a') assert r.get('r') is None + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.6.0') def test_bitop_not(self, r): test_str = b'\xAA\x00\xFF\x55' @@ -691,6 +734,7 @@ def test_bitop_not(self, r): r.bitop('not', 'r', 'a') assert int(binascii.hexlify(r['r']), 16) == correct + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.6.0') def test_bitop_not_in_place(self, r): test_str = b'\xAA\x00\xFF\x55' @@ -699,6 +743,7 @@ def test_bitop_not_in_place(self, r): r.bitop('not', 'a', 'a') assert int(binascii.hexlify(r['a']), 16) == correct + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.6.0') def test_bitop_single_string(self, r): test_str = b'\x01\x02\xFF' @@ -710,6 +755,7 @@ def test_bitop_single_string(self, r): assert r['res2'] == test_str assert r['res3'] == test_str + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.6.0') def test_bitop_string_operands(self, r): r['a'] = b'\x01\x02\xFF\xFF' @@ -721,6 +767,7 @@ def test_bitop_string_operands(self, r): assert int(binascii.hexlify(r['res2']), 16) == 0x0102FFFF assert int(binascii.hexlify(r['res3']), 16) == 0x000000FF + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.7') def test_bitpos(self, r): key = 'key:bitpos' @@ -743,6 +790,7 @@ def test_bitpos_wrong_arguments(self, r): with pytest.raises(exceptions.RedisError): r.bitpos(key, 7) == 12 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_copy(self, r): assert r.copy("a", "b") == 0 @@ -751,6 +799,7 @@ def test_copy(self, r): assert r.get("a") == b"foo" assert r.get("b") == b"foo" + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_copy_and_replace(self, r): r.set("a", "foo1") @@ -758,6 +807,7 @@ def test_copy_and_replace(self, r): assert r.copy("a", "b") == 0 assert r.copy("a", "b", replace=True) == 1 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_copy_to_another_database(self, request): r0 = _get_client(redis.Redis, request, db=0) @@ -973,6 +1023,7 @@ def test_incrbyfloat(self, r): assert r.incrbyfloat('a', 1.1) == 2.1 assert float(r['a']) == float(2.1) + @pytest.mark.onlynoncluster def test_keys(self, r): assert r.keys() == [] keys_with_underscores = {b'test_a', b'test_b'} @@ -982,6 +1033,7 @@ def test_keys(self, r): assert set(r.keys(pattern='test_*')) == keys_with_underscores assert set(r.keys(pattern='test*')) == keys + @pytest.mark.onlynoncluster def test_mget(self, r): assert r.mget([]) == [] assert r.mget(['a', 'b']) == [None, None] @@ -990,24 +1042,28 @@ def test_mget(self, r): r['c'] = '3' assert r.mget('a', 'other', 'b', 'c') == [b'1', None, b'2', b'3'] + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_lmove(self, r): r.rpush('a', 'one', 'two', 'three', 'four') assert r.lmove('a', 'b') assert r.lmove('a', 'b', 'right', 'left') + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_blmove(self, r): r.rpush('a', 'one', 'two', 'three', 'four') assert r.blmove('a', 'b', 5) assert r.blmove('a', 'b', 1, 'RIGHT', 'LEFT') + @pytest.mark.onlynoncluster def test_mset(self, r): d = {'a': b'1', 'b': b'2', 'c': b'3'} assert r.mset(d) for k, v in d.items(): assert r[k] == v + @pytest.mark.onlynoncluster def test_msetnx(self, r): d = {'a': b'1', 'b': b'2', 'c': b'3'} assert r.msetnx(d) @@ -1086,18 +1142,21 @@ def test_hrandfield(self, r): # with duplications assert len(r.hrandfield('key', -10)) == 10 + @pytest.mark.onlynoncluster def test_randomkey(self, r): assert r.randomkey() is None for key in ('a', 'b', 'c'): r[key] = 1 assert r.randomkey() in (b'a', b'b', b'c') + @pytest.mark.onlynoncluster def test_rename(self, r): r['a'] = '1' assert r.rename('a', 'b') assert r.get('a') is None assert r['b'] == b'1' + @pytest.mark.onlynoncluster def test_renamenx(self, r): r['a'] = '1' r['b'] = '2' @@ -1203,8 +1262,8 @@ def test_setrange(self, r): @skip_if_server_version_lt('6.0.0') def test_stralgo_lcs(self, r): - key1 = 'key1' - key2 = 'key2' + key1 = '{foo}key1' + key2 = '{foo}key2' value1 = 'ohmytext' value2 = 'mynewtext' res = 'mytext' @@ -1294,6 +1353,7 @@ def test_type(self, r): assert r.type('a') == b'zset' # LIST COMMANDS + @pytest.mark.onlynoncluster def test_blpop(self, r): r.rpush('a', '1', '2') r.rpush('b', '3', '4') @@ -1305,6 +1365,7 @@ def test_blpop(self, r): r.rpush('c', '1') assert r.blpop('c', timeout=1) == (b'c', b'1') + @pytest.mark.onlynoncluster def test_brpop(self, r): r.rpush('a', '1', '2') r.rpush('b', '3', '4') @@ -1316,6 +1377,7 @@ def test_brpop(self, r): r.rpush('c', '1') assert r.brpop('c', timeout=1) == (b'c', b'1') + @pytest.mark.onlynoncluster def test_brpoplpush(self, r): r.rpush('a', '1', '2') r.rpush('b', '3', '4') @@ -1325,6 +1387,7 @@ def test_brpoplpush(self, r): assert r.lrange('a', 0, -1) == [] assert r.lrange('b', 0, -1) == [b'1', b'2', b'3', b'4'] + @pytest.mark.onlynoncluster def test_brpoplpush_empty_string(self, r): r.rpush('a', '') assert r.brpoplpush('a', 'b') == b'' @@ -1428,6 +1491,7 @@ def test_rpop_count(self, r): assert r.rpop('a') is None assert r.rpop('a', 3) is None + @pytest.mark.onlynoncluster def test_rpoplpush(self, r): r.rpush('a', 'a1', 'a2', 'a3') r.rpush('b', 'b1', 'b2', 'b3') @@ -1481,6 +1545,7 @@ def test_rpushx(self, r): assert r.lrange('a', 0, -1) == [b'1', b'2', b'3', b'4'] # SCAN COMMANDS + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.0') def test_scan(self, r): r.set('a', 1) @@ -1492,6 +1557,7 @@ def test_scan(self, r): _, keys = r.scan(match='a') assert set(keys) == {b'a'} + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_scan_type(self, r): r.sadd('a-set', 1) @@ -1500,6 +1566,7 @@ def test_scan_type(self, r): _, keys = r.scan(match='a*', _type='SET') assert set(keys) == {b'a-set'} + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.0') def test_scan_iter(self, r): r.set('a', 1) @@ -1571,12 +1638,14 @@ def test_scard(self, r): r.sadd('a', '1', '2', '3') assert r.scard('a') == 3 + @pytest.mark.onlynoncluster def test_sdiff(self, r): r.sadd('a', '1', '2', '3') assert r.sdiff('a', 'b') == {b'1', b'2', b'3'} r.sadd('b', '2', '3') assert r.sdiff('a', 'b') == {b'1'} + @pytest.mark.onlynoncluster def test_sdiffstore(self, r): r.sadd('a', '1', '2', '3') assert r.sdiffstore('c', 'a', 'b') == 3 @@ -1585,12 +1654,14 @@ def test_sdiffstore(self, r): assert r.sdiffstore('c', 'a', 'b') == 1 assert r.smembers('c') == {b'1'} + @pytest.mark.onlynoncluster def test_sinter(self, r): r.sadd('a', '1', '2', '3') assert r.sinter('a', 'b') == set() r.sadd('b', '2', '3') assert r.sinter('a', 'b') == {b'2', b'3'} + @pytest.mark.onlynoncluster def test_sinterstore(self, r): r.sadd('a', '1', '2', '3') assert r.sinterstore('c', 'a', 'b') == 0 @@ -1617,6 +1688,7 @@ def test_smismember(self, r): assert r.smismember('a', '1', '4', '2', '3') == result_list assert r.smismember('a', ['1', '4', '2', '3']) == result_list + @pytest.mark.onlynoncluster def test_smove(self, r): r.sadd('a', 'a1', 'a2') r.sadd('b', 'b1', 'b2') @@ -1662,11 +1734,13 @@ def test_srem(self, r): assert r.srem('a', '2', '4') == 2 assert r.smembers('a') == {b'1', b'3'} + @pytest.mark.onlynoncluster def test_sunion(self, r): r.sadd('a', '1', '2') r.sadd('b', '2', '3') assert r.sunion('a', 'b') == {b'1', b'2', b'3'} + @pytest.mark.onlynoncluster def test_sunionstore(self, r): r.sadd('a', '1', '2') r.sadd('b', '2', '3') @@ -1678,6 +1752,7 @@ def test_debug_segfault(self, r): with pytest.raises(NotImplementedError): r.debug_segfault() + @pytest.mark.onlynoncluster @skip_if_server_version_lt('3.2.0') def test_script_debug(self, r): with pytest.raises(NotImplementedError): @@ -1759,6 +1834,7 @@ def test_zcount(self, r): assert r.zcount('a', 1, '(' + str(2)) == 1 assert r.zcount('a', 10, 20) == 0 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_zdiff(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) @@ -1766,6 +1842,7 @@ def test_zdiff(self, r): assert r.zdiff(['a', 'b']) == [b'a3'] assert r.zdiff(['a', 'b'], withscores=True) == [b'a3', b'3'] + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_zdiffstore(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) @@ -1787,6 +1864,7 @@ def test_zlexcount(self, r): assert r.zlexcount('a', '-', '+') == 7 assert r.zlexcount('a', '[b', '[f') == 5 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_zinter(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 1}) @@ -1809,6 +1887,7 @@ def test_zinter(self, r): assert r.zinter({'a': 1, 'b': 2, 'c': 3}, withscores=True) \ == [(b'a3', 20), (b'a1', 23)] + @pytest.mark.onlynoncluster def test_zinterstore_sum(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) @@ -1817,6 +1896,7 @@ def test_zinterstore_sum(self, r): assert r.zrange('d', 0, -1, withscores=True) == \ [(b'a3', 8), (b'a1', 9)] + @pytest.mark.onlynoncluster def test_zinterstore_max(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) @@ -1825,6 +1905,7 @@ def test_zinterstore_max(self, r): assert r.zrange('d', 0, -1, withscores=True) == \ [(b'a3', 5), (b'a1', 6)] + @pytest.mark.onlynoncluster def test_zinterstore_min(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) r.zadd('b', {'a1': 2, 'a2': 3, 'a3': 5}) @@ -1833,6 +1914,7 @@ def test_zinterstore_min(self, r): assert r.zrange('d', 0, -1, withscores=True) == \ [(b'a1', 1), (b'a3', 3)] + @pytest.mark.onlynoncluster def test_zinterstore_with_weight(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) @@ -1871,6 +1953,7 @@ def test_zrandemember(self, r): # with duplications assert len(r.zrandmember('a', -10)) == 10 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('4.9.0') def test_bzpopmax(self, r): r.zadd('a', {'a1': 1, 'a2': 2}) @@ -1883,6 +1966,7 @@ def test_bzpopmax(self, r): r.zadd('c', {'c1': 100}) assert r.bzpopmax('c', timeout=1) == (b'c', b'c1', 100) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('4.9.0') def test_bzpopmin(self, r): r.zadd('a', {'a1': 1, 'a2': 2}) @@ -1952,6 +2036,7 @@ def test_zrange_params(self, r): # rev assert r.zrange('a', 0, 1, desc=True) == [b'a5', b'a4'] + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_zrangestore(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) @@ -2089,6 +2174,7 @@ def test_zscore(self, r): assert r.zscore('a', 'a2') == 2.0 assert r.zscore('a', 'a4') is None + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_zunion(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) @@ -2109,6 +2195,7 @@ def test_zunion(self, r): assert r.zunion({'a': 1, 'b': 2, 'c': 3}, withscores=True)\ == [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] + @pytest.mark.onlynoncluster def test_zunionstore_sum(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) @@ -2117,6 +2204,7 @@ def test_zunionstore_sum(self, r): assert r.zrange('d', 0, -1, withscores=True) == \ [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + @pytest.mark.onlynoncluster def test_zunionstore_max(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) @@ -2125,6 +2213,7 @@ def test_zunionstore_max(self, r): assert r.zrange('d', 0, -1, withscores=True) == \ [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + @pytest.mark.onlynoncluster def test_zunionstore_min(self, r): r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 4}) @@ -2133,6 +2222,7 @@ def test_zunionstore_min(self, r): assert r.zrange('d', 0, -1, withscores=True) == \ [(b'a1', 1), (b'a2', 2), (b'a3', 3), (b'a4', 4)] + @pytest.mark.onlynoncluster def test_zunionstore_with_weight(self, r): r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) @@ -2160,6 +2250,7 @@ def test_pfadd(self, r): assert r.pfadd('a', *members) == 0 assert r.pfcount('a') == len(members) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.9') def test_pfcount(self, r): members = {b'1', b'2', b'3'} @@ -2170,6 +2261,7 @@ def test_pfcount(self, r): assert r.pfcount('b') == len(members_b) assert r.pfcount('a', 'b') == len(members_b.union(members)) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.9') def test_pfmerge(self, r): mema = {b'1', b'2', b'3'} @@ -2264,8 +2356,9 @@ def test_hmget(self, r): assert r.hmget('a', 'a', 'b', 'c') == [b'1', b'2', b'3'] def test_hmset(self, r): - warning_message = (r'^Redis\.hmset\(\) is deprecated\. ' - r'Use Redis\.hset\(\) instead\.$') + redis_class = type(r).__name__ + warning_message = (r'^{0}\.hmset\(\) is deprecated\. ' + r'Use {0}\.hset\(\) instead\.$'.format(redis_class)) h = {b'a': b'1', b'b': b'2', b'c': b'3'} with pytest.warns(DeprecationWarning, match=warning_message): assert r.hmset('a', h) @@ -2300,6 +2393,7 @@ def test_sort_limited(self, r): r.rpush('a', '3', '2', '1', '4') assert r.sort('a', start=1, num=2) == [b'2', b'3'] + @pytest.mark.onlynoncluster def test_sort_by(self, r): r['score:1'] = 8 r['score:2'] = 3 @@ -2307,6 +2401,7 @@ def test_sort_by(self, r): r.rpush('a', '3', '2', '1') assert r.sort('a', by='score:*') == [b'2', b'3', b'1'] + @pytest.mark.onlynoncluster def test_sort_get(self, r): r['user:1'] = 'u1' r['user:2'] = 'u2' @@ -2314,6 +2409,7 @@ def test_sort_get(self, r): r.rpush('a', '2', '3', '1') assert r.sort('a', get='user:*') == [b'u1', b'u2', b'u3'] + @pytest.mark.onlynoncluster def test_sort_get_multi(self, r): r['user:1'] = 'u1' r['user:2'] = 'u2' @@ -2322,6 +2418,7 @@ def test_sort_get_multi(self, r): assert r.sort('a', get=('user:*', '#')) == \ [b'u1', b'1', b'u2', b'2', b'u3', b'3'] + @pytest.mark.onlynoncluster def test_sort_get_groups_two(self, r): r['user:1'] = 'u1' r['user:2'] = 'u2' @@ -2330,6 +2427,7 @@ def test_sort_get_groups_two(self, r): assert r.sort('a', get=('user:*', '#'), groups=True) == \ [(b'u1', b'1'), (b'u2', b'2'), (b'u3', b'3')] + @pytest.mark.onlynoncluster def test_sort_groups_string_get(self, r): r['user:1'] = 'u1' r['user:2'] = 'u2' @@ -2338,6 +2436,7 @@ def test_sort_groups_string_get(self, r): with pytest.raises(exceptions.DataError): r.sort('a', get='user:*', groups=True) + @pytest.mark.onlynoncluster def test_sort_groups_just_one_get(self, r): r['user:1'] = 'u1' r['user:2'] = 'u2' @@ -2354,6 +2453,7 @@ def test_sort_groups_no_get(self, r): with pytest.raises(exceptions.DataError): r.sort('a', groups=True) + @pytest.mark.onlynoncluster def test_sort_groups_three_gets(self, r): r['user:1'] = 'u1' r['user:2'] = 'u2' @@ -2378,11 +2478,13 @@ def test_sort_alpha(self, r): assert r.sort('a', alpha=True) == \ [b'a', b'b', b'c', b'd', b'e'] + @pytest.mark.onlynoncluster def test_sort_store(self, r): r.rpush('a', '2', '3', '1') assert r.sort('a', store='sorted_values') == 3 assert r.lrange('sorted_values', 0, -1) == [b'1', b'2', b'3'] + @pytest.mark.onlynoncluster def test_sort_all_options(self, r): r['user:1:username'] = 'zeus' r['user:2:username'] = 'titan' @@ -2415,66 +2517,84 @@ def test_sort_issue_924(self, r): r.execute_command('SADD', 'issue#924', 1) r.execute_command('SORT', 'issue#924') + @pytest.mark.onlynoncluster def test_cluster_addslots(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('ADDSLOTS', 1) is True + @pytest.mark.onlynoncluster def test_cluster_count_failure_reports(self, mock_cluster_resp_int): assert isinstance(mock_cluster_resp_int.cluster( 'COUNT-FAILURE-REPORTS', 'node'), int) + @pytest.mark.onlynoncluster def test_cluster_countkeysinslot(self, mock_cluster_resp_int): assert isinstance(mock_cluster_resp_int.cluster( 'COUNTKEYSINSLOT', 2), int) + @pytest.mark.onlynoncluster def test_cluster_delslots(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('DELSLOTS', 1) is True + @pytest.mark.onlynoncluster def test_cluster_failover(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('FAILOVER', 1) is True + @pytest.mark.onlynoncluster def test_cluster_forget(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('FORGET', 1) is True + @pytest.mark.onlynoncluster def test_cluster_info(self, mock_cluster_resp_info): assert isinstance(mock_cluster_resp_info.cluster('info'), dict) + @pytest.mark.onlynoncluster def test_cluster_keyslot(self, mock_cluster_resp_int): assert isinstance(mock_cluster_resp_int.cluster( 'keyslot', 'asdf'), int) + @pytest.mark.onlynoncluster def test_cluster_meet(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('meet', 'ip', 'port', 1) is True + @pytest.mark.onlynoncluster def test_cluster_nodes(self, mock_cluster_resp_nodes): assert isinstance(mock_cluster_resp_nodes.cluster('nodes'), dict) + @pytest.mark.onlynoncluster def test_cluster_replicate(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('replicate', 'nodeid') is True + @pytest.mark.onlynoncluster def test_cluster_reset(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('reset', 'hard') is True + @pytest.mark.onlynoncluster def test_cluster_saveconfig(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('saveconfig') is True + @pytest.mark.onlynoncluster def test_cluster_setslot(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.cluster('setslot', 1, 'IMPORTING', 'nodeid') is True + @pytest.mark.onlynoncluster def test_cluster_slaves(self, mock_cluster_resp_slaves): assert isinstance(mock_cluster_resp_slaves.cluster( 'slaves', 'nodeid'), dict) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('3.0.0') @skip_if_redis_enterprise def test_readwrite(self, r): assert r.readwrite() + @pytest.mark.onlynoncluster @skip_if_server_version_lt('3.0.0') def test_readonly_invalid_cluster_state(self, r): with pytest.raises(exceptions.RedisError): r.readonly() + @pytest.mark.onlynoncluster @skip_if_server_version_lt('3.0.0') def test_readonly(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.readonly() is True @@ -2701,6 +2821,7 @@ def test_geosearch_negative(self, r): with pytest.raises(exceptions.DataError): assert r.geosearch('barcelona', member='place3', radius=100, any=1) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('6.2.0') def test_geosearchstore(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ @@ -2711,6 +2832,7 @@ def test_geosearchstore(self, r): longitude=2.191, latitude=41.433, radius=1000) assert r.zrange('places_barcelona', 0, -1) == [b'place1'] + @pytest.mark.onlynoncluster @skip_unless_arch_bits(64) @skip_if_server_version_lt('6.2.0') def test_geosearchstore_dist(self, r): @@ -2802,6 +2924,7 @@ def test_georadius_sort(self, r): assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='DESC') == \ [b'place2', b'place1'] + @pytest.mark.onlynoncluster @skip_if_server_version_lt('3.2.0') def test_georadius_store(self, r): values = (2.1909389952632, 41.433791470673, 'place1') + \ @@ -2811,6 +2934,7 @@ def test_georadius_store(self, r): r.georadius('barcelona', 2.191, 41.433, 1000, store='places_barcelona') assert r.zrange('places_barcelona', 0, -1) == [b'place1'] + @pytest.mark.onlynoncluster @skip_unless_arch_bits(64) @skip_if_server_version_lt('3.2.0') def test_georadius_store_dist(self, r): @@ -3658,6 +3782,7 @@ def test_memory_usage(self, r): r.set('foo', 'bar') assert isinstance(r.memory_usage('foo'), int) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('4.0.0') @skip_if_redis_enterprise def test_module_list(self, r): @@ -3675,10 +3800,11 @@ def test_command_count(self, r): def test_command(self, r): res = r.command() assert len(res) >= 100 - cmds = [c[0].decode() for c in res] + cmds = list(res.keys()) assert 'set' in cmds assert 'get' in cmds + @pytest.mark.onlynoncluster @skip_if_server_version_lt('4.0.0') @skip_if_redis_enterprise def test_module(self, r): @@ -3734,6 +3860,7 @@ def test_restore_frequency(self, r): assert r.restore(key, 0, dumpdata, frequency=5) assert r.get(key) == b'blee!' + @pytest.mark.onlynoncluster @skip_if_server_version_lt('5.0.0') @skip_if_redis_enterprise def test_replicaof(self, r): @@ -3742,6 +3869,7 @@ def test_replicaof(self, r): assert r.replicaof("NO", "ONE") +@pytest.mark.onlynoncluster class TestBinarySave: def test_binary_get_set(self, r): diff --git a/tests/test_connection.py b/tests/test_connection.py index 7c44768150..cd8907d85c 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -8,6 +8,7 @@ @pytest.mark.skipif(HIREDIS_AVAILABLE, reason='PythonParser only') +@pytest.mark.onlynoncluster def test_invalid_response(r): raw = b'x' parser = r.connection._parser diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 521f520777..288d43dfd7 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -484,6 +484,7 @@ def test_on_connect_error(self): assert len(pool._available_connections) == 1 assert not pool._available_connections[0]._sock + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.8') @skip_if_redis_enterprise def test_busy_loading_disconnects_socket(self, r): @@ -495,6 +496,7 @@ def test_busy_loading_disconnects_socket(self, r): r.execute_command('DEBUG', 'ERROR', 'LOADING fake message') assert not r.connection._sock + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.8') @skip_if_redis_enterprise def test_busy_loading_from_pipeline_immediate_command(self, r): @@ -511,6 +513,7 @@ def test_busy_loading_from_pipeline_immediate_command(self, r): assert len(pool._available_connections) == 1 assert not pool._available_connections[0]._sock + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.8') @skip_if_redis_enterprise def test_busy_loading_from_pipeline(self, r): @@ -571,6 +574,7 @@ def test_connect_invalid_password_supplied(self, r): r.execute_command('DEBUG', 'ERROR', 'ERR invalid password') +@pytest.mark.onlynoncluster class TestMultiConnectionClient: @pytest.fixture() def r(self, request): @@ -584,6 +588,7 @@ def test_multi_connection_command(self, r): assert r.get('a') == b'123' +@pytest.mark.onlynoncluster class TestHealthCheck: interval = 60 diff --git a/tests/test_json.py b/tests/test_json.py index abc5776316..092d301b2d 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1391,6 +1391,7 @@ def test_arrindex_dollar(client): "None") == 0 +@pytest.mark.redismod def test_decoders_and_unstring(): assert unstring("4") == 4 assert unstring("45.55") == 45.55 diff --git a/tests/test_lock.py b/tests/test_lock.py index fa76385221..66148edcfc 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -7,6 +7,7 @@ from .conftest import _get_client +@pytest.mark.onlynoncluster class TestLock: @pytest.fixture() def r_decoded(self, request): @@ -220,6 +221,7 @@ def test_reacquiring_lock_no_longer_owned_raises_error(self, r): lock.reacquire() +@pytest.mark.onlynoncluster class TestLockClassSelection: def test_lock_class_argument(self, r): class MyLock: diff --git a/tests/test_monitor.py b/tests/test_monitor.py index a8a535b59a..6c3ea33bce 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -1,3 +1,4 @@ +import pytest from .conftest import ( skip_if_redis_enterprise, skip_ifnot_redis_enterprise, @@ -5,6 +6,7 @@ ) +@pytest.mark.onlynoncluster class TestMonitor: def test_wait_command_not_found(self, r): "Make sure the wait_for_command func works when command is not found" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 08bd40bacd..a759bc944e 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -59,6 +59,7 @@ def test_pipeline_no_transaction(self, r): assert r['b'] == b'b1' assert r['c'] == b'c1' + @pytest.mark.onlynoncluster def test_pipeline_no_transaction_watch(self, r): r['a'] = 0 @@ -70,6 +71,7 @@ def test_pipeline_no_transaction_watch(self, r): pipe.set('a', int(a) + 1) assert pipe.execute() == [True] + @pytest.mark.onlynoncluster def test_pipeline_no_transaction_watch_failure(self, r): r['a'] = 0 @@ -129,6 +131,7 @@ def test_exec_error_raised(self, r): assert pipe.set('z', 'zzz').execute() == [True] assert r['z'] == b'zzz' + @pytest.mark.onlynoncluster def test_transaction_with_empty_error_command(self, r): """ Commands with custom EMPTY_ERROR functionality return their default @@ -143,6 +146,7 @@ def test_transaction_with_empty_error_command(self, r): assert result[1] == [] assert result[2] + @pytest.mark.onlynoncluster def test_pipeline_with_empty_error_command(self, r): """ Commands with custom EMPTY_ERROR functionality return their default @@ -171,6 +175,7 @@ def test_parse_error_raised(self, r): assert pipe.set('z', 'zzz').execute() == [True] assert r['z'] == b'zzz' + @pytest.mark.onlynoncluster def test_parse_error_raised_transaction(self, r): with r.pipeline() as pipe: pipe.multi() @@ -186,6 +191,7 @@ def test_parse_error_raised_transaction(self, r): assert pipe.set('z', 'zzz').execute() == [True] assert r['z'] == b'zzz' + @pytest.mark.onlynoncluster def test_watch_succeed(self, r): r['a'] = 1 r['b'] = 2 @@ -203,6 +209,7 @@ def test_watch_succeed(self, r): assert pipe.execute() == [True] assert not pipe.watching + @pytest.mark.onlynoncluster def test_watch_failure(self, r): r['a'] = 1 r['b'] = 2 @@ -217,6 +224,7 @@ def test_watch_failure(self, r): assert not pipe.watching + @pytest.mark.onlynoncluster def test_watch_failure_in_empty_transaction(self, r): r['a'] = 1 r['b'] = 2 @@ -230,6 +238,7 @@ def test_watch_failure_in_empty_transaction(self, r): assert not pipe.watching + @pytest.mark.onlynoncluster def test_unwatch(self, r): r['a'] = 1 r['b'] = 2 @@ -242,6 +251,7 @@ def test_unwatch(self, r): pipe.get('a') assert pipe.execute() == [b'1'] + @pytest.mark.onlynoncluster def test_watch_exec_no_unwatch(self, r): r['a'] = 1 r['b'] = 2 @@ -262,6 +272,7 @@ def test_watch_exec_no_unwatch(self, r): unwatch_command = wait_for_command(r, m, 'UNWATCH') assert unwatch_command is None, "should not send UNWATCH" + @pytest.mark.onlynoncluster def test_watch_reset_unwatch(self, r): r['a'] = 1 @@ -276,6 +287,7 @@ def test_watch_reset_unwatch(self, r): assert unwatch_command is not None assert unwatch_command['command'] == 'UNWATCH' + @pytest.mark.onlynoncluster def test_transaction_callable(self, r): r['a'] = 1 r['b'] = 2 @@ -300,6 +312,7 @@ def my_transaction(pipe): assert result == [True] assert r['c'] == b'4' + @pytest.mark.onlynoncluster def test_transaction_callable_returns_value_from_callable(self, r): def callback(pipe): # No need to do anything here since we only want the return value @@ -354,6 +367,7 @@ def test_pipeline_with_bitfield(self, r): assert pipe == pipe2 assert response == [True, [0, 0, 15, 15, 14], b'1'] + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.0.0') def test_pipeline_discard(self, r): diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index e2424592cd..95513a09a8 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -123,6 +123,7 @@ def test_resubscribe_to_channels_on_reconnection(self, r): kwargs = make_subscribe_test_data(r.pubsub(), 'channel') self._test_resubscribe_on_reconnection(**kwargs) + @pytest.mark.onlynoncluster def test_resubscribe_to_patterns_on_reconnection(self, r): kwargs = make_subscribe_test_data(r.pubsub(), 'pattern') self._test_resubscribe_on_reconnection(**kwargs) @@ -177,6 +178,7 @@ def test_subscribe_property_with_channels(self, r): kwargs = make_subscribe_test_data(r.pubsub(), 'channel') self._test_subscribed_property(**kwargs) + @pytest.mark.onlynoncluster def test_subscribe_property_with_patterns(self, r): kwargs = make_subscribe_test_data(r.pubsub(), 'pattern') self._test_subscribed_property(**kwargs) @@ -220,6 +222,7 @@ def test_sub_unsub_resub_channels(self, r): kwargs = make_subscribe_test_data(r.pubsub(), 'channel') self._test_sub_unsub_resub(**kwargs) + @pytest.mark.onlynoncluster def test_sub_unsub_resub_patterns(self, r): kwargs = make_subscribe_test_data(r.pubsub(), 'pattern') self._test_sub_unsub_resub(**kwargs) @@ -307,6 +310,7 @@ def test_channel_message_handler(self, r): assert wait_for_message(p) is None assert self.message == make_message('message', 'foo', 'test message') + @pytest.mark.onlynoncluster def test_pattern_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) p.psubscribe(**{'f*': self.message_handler}) @@ -326,6 +330,9 @@ def test_unicode_channel_message_handler(self, r): assert wait_for_message(p) is None assert self.message == make_message('message', channel, 'test message') + @pytest.mark.onlynoncluster + # see: https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html + # #known-limitations-with-pubsub def test_unicode_pattern_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) pattern = 'uni' + chr(4456) + '*' @@ -401,6 +408,7 @@ def test_channel_publish(self, r): self.channel, self.data) + @pytest.mark.onlynoncluster def test_pattern_publish(self, r): p = r.pubsub() p.psubscribe(self.pattern) @@ -473,6 +481,7 @@ def test_channel_subscribe(self, r): class TestPubSubSubcommands: + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.0') def test_pubsub_channels(self, r): p = r.pubsub() @@ -482,6 +491,7 @@ def test_pubsub_channels(self, r): expected = [b'bar', b'baz', b'foo', b'quux'] assert all([channel in r.pubsub_channels() for channel in expected]) + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.0') def test_pubsub_numsub(self, r): p1 = r.pubsub() @@ -497,7 +507,7 @@ def test_pubsub_numsub(self, r): assert wait_for_message(p3)['type'] == 'subscribe' channels = [(b'foo', 1), (b'bar', 2), (b'baz', 3)] - assert channels == r.pubsub_numsub('foo', 'bar', 'baz') + assert r.pubsub_numsub('foo', 'bar', 'baz') == channels @skip_if_server_version_lt('2.8.0') def test_pubsub_numpat(self, r): @@ -529,6 +539,7 @@ def test_send_pubsub_ping_message(self, r): pattern=None) +@pytest.mark.onlynoncluster class TestPubSubConnectionKilled: @skip_if_server_version_lt('3.0.0') diff --git a/tests/test_scripting.py b/tests/test_scripting.py index 352f3bae2e..7614b1233f 100644 --- a/tests/test_scripting.py +++ b/tests/test_scripting.py @@ -22,6 +22,7 @@ """ +@pytest.mark.onlynoncluster class TestScripting: @pytest.fixture(autouse=True) def reset_scripts(self, r): diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py index 7f3ff0ae2a..9377d5ba65 100644 --- a/tests/test_sentinel.py +++ b/tests/test_sentinel.py @@ -81,16 +81,19 @@ def sentinel(request, cluster): return Sentinel([('foo', 26379), ('bar', 26379)]) +@pytest.mark.onlynoncluster def test_discover_master(sentinel, master_ip): address = sentinel.discover_master('mymaster') assert address == (master_ip, 6379) +@pytest.mark.onlynoncluster def test_discover_master_error(sentinel): with pytest.raises(MasterNotFoundError): sentinel.discover_master('xxx') +@pytest.mark.onlynoncluster def test_discover_master_sentinel_down(cluster, sentinel, master_ip): # Put first sentinel 'foo' down cluster.nodes_down.add(('foo', 26379)) @@ -100,6 +103,7 @@ def test_discover_master_sentinel_down(cluster, sentinel, master_ip): assert sentinel.sentinels[0].id == ('bar', 26379) +@pytest.mark.onlynoncluster def test_discover_master_sentinel_timeout(cluster, sentinel, master_ip): # Put first sentinel 'foo' down cluster.nodes_timeout.add(('foo', 26379)) @@ -109,6 +113,7 @@ def test_discover_master_sentinel_timeout(cluster, sentinel, master_ip): assert sentinel.sentinels[0].id == ('bar', 26379) +@pytest.mark.onlynoncluster def test_master_min_other_sentinels(cluster, master_ip): sentinel = Sentinel([('foo', 26379)], min_other_sentinels=1) # min_other_sentinels @@ -119,18 +124,21 @@ def test_master_min_other_sentinels(cluster, master_ip): assert address == (master_ip, 6379) +@pytest.mark.onlynoncluster def test_master_odown(cluster, sentinel): cluster.master['is_odown'] = True with pytest.raises(MasterNotFoundError): sentinel.discover_master('mymaster') +@pytest.mark.onlynoncluster def test_master_sdown(cluster, sentinel): cluster.master['is_sdown'] = True with pytest.raises(MasterNotFoundError): sentinel.discover_master('mymaster') +@pytest.mark.onlynoncluster def test_discover_slaves(cluster, sentinel): assert sentinel.discover_slaves('mymaster') == [] @@ -165,6 +173,7 @@ def test_discover_slaves(cluster, sentinel): ('slave0', 1234), ('slave1', 1234)] +@pytest.mark.onlynoncluster def test_master_for(cluster, sentinel, master_ip): master = sentinel.master_for('mymaster', db=9) assert master.ping() @@ -175,6 +184,7 @@ def test_master_for(cluster, sentinel, master_ip): assert master.ping() +@pytest.mark.onlynoncluster def test_slave_for(cluster, sentinel): cluster.slaves = [ {'ip': '127.0.0.1', 'port': 6379, @@ -184,6 +194,7 @@ def test_slave_for(cluster, sentinel): assert slave.ping() +@pytest.mark.onlynoncluster def test_slave_for_slave_not_found_error(cluster, sentinel): cluster.master['is_odown'] = True slave = sentinel.slave_for('mymaster', db=9) @@ -191,6 +202,7 @@ def test_slave_for_slave_not_found_error(cluster, sentinel): slave.ping() +@pytest.mark.onlynoncluster def test_slave_round_robin(cluster, sentinel, master_ip): cluster.slaves = [ {'ip': 'slave0', 'port': 6379, 'is_odown': False, 'is_sdown': False}, @@ -206,14 +218,17 @@ def test_slave_round_robin(cluster, sentinel, master_ip): next(rotator) +@pytest.mark.onlynoncluster def test_ckquorum(cluster, sentinel): assert sentinel.sentinel_ckquorum("mymaster") +@pytest.mark.onlynoncluster def test_flushconfig(cluster, sentinel): assert sentinel.sentinel_flushconfig() +@pytest.mark.onlynoncluster def test_reset(cluster, sentinel): cluster.master['is_odown'] = True assert sentinel.sentinel_reset('mymaster') diff --git a/tox.ini b/tox.ini index 6d4c65849b..c68dab18ea 100644 --- a/tox.ini +++ b/tox.ini @@ -2,11 +2,13 @@ addopts = -s markers = redismod: run only the redis module tests + onlycluster: marks tests to be run only with cluster mode redis + onlynoncluster: marks tests to be run only with non-cluster redis [tox] minversion = 3.2.0 requires = tox-docker -envlist = {py35,py36,py37,py38,py39,pypy3}-{plain,hiredis},linters +envlist = {redis,cluster}-{plain,hiredis}-{py35,py36,py37,py38,py39,pypy3},linters [docker:master] name = master @@ -74,6 +76,21 @@ image = redisfab/lots-of-pythons volumes = bind:rw:{toxinidir}:/data +[docker:redis_cluster] +name = redis_cluster +image = barshaul/redis-py:6.2.6-cluster +ports = + 16379:16379/tcp + 16380:16380/tcp + 16381:16381/tcp + 16382:16382/tcp + 16383:16383/tcp + 16384:16384/tcp +healtcheck_cmd = python -c "import socket;print(True) if all([0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',port)) for port in range(16379,16384)]) else False" +volumes = + bind:rw:{toxinidir}/docker/cluster/redis.conf:/redis.conf + + [testenv] deps = -r {toxinidir}/requirements.txt @@ -84,11 +101,15 @@ docker = sentinel_1 sentinel_2 sentinel_3 + redis_cluster redismod extras = hiredis: hiredis +setenv = + CLUSTER_URL = "redis://localhost:16379/0" commands = - pytest --cov=./ --cov-report=xml -W always {posargs} + redis: pytest --cov=./ --cov-report=xml:coverage_redis.xml -W always -m 'not onlycluster' {posargs} + cluster: pytest --cov=./ --cov-report=xml:coverage_cluster.xml -W always -m 'not onlynoncluster and not redismod' --redis-url={env:CLUSTER_URL:} {posargs} [testenv:devenv] skipsdist = true @@ -100,6 +121,7 @@ docker = sentinel_1 sentinel_2 sentinel_3 + redis_cluster redismod lots-of-pythons commands = /usr/bin/echo From b600e303cd540480fd934d1b9ce8d4d248e2395b Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 14:16:14 +0200 Subject: [PATCH 0264/1164] GitHub release improvements (#1684) --- .github/workflows/pypi-publish.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml index b842c36670..3cccb067ac 100644 --- a/.github/workflows/pypi-publish.yaml +++ b/.github/workflows/pypi-publish.yaml @@ -13,7 +13,7 @@ jobs: - name: install python uses: actions/setup-python@v2 with: - python-version: 3.0 + python-version: 3.9 - name: Install dev tools run: | pip install -r dev_requirements.txt @@ -22,7 +22,7 @@ jobs: - name: Build package run: | python setup.py build - python setup.py dist bdist_wheel + python setup.py sdist bdist_wheel - name: Publish to Pypi uses: pypa/gh-action-pypi-publish@release/v1 From e5786c218d0b2ac11f2f13be55f5c70c09d247fb Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 14:16:35 +0200 Subject: [PATCH 0265/1164] RedisJSON 2.0.4 behaviour support (#1747) --- tests/conftest.py | 5 ++-- tests/test_json.py | 53 +++++++++++++++++++++++----------------- tests/test_timeseries.py | 1 - 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f29ebdebb1..ddc0834037 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -149,13 +149,12 @@ def skip_ifmodversion_lt(min_version: str, module_name: str): def skip_if_redis_enterprise(func): check = REDIS_INFO["enterprise"] is True - return pytest.mark.skipif(check, reason="Redis enterprise" - ) + return pytest.mark.skipif(check, reason="Redis enterprise") def skip_ifnot_redis_enterprise(func): check = REDIS_INFO["enterprise"] is False - return pytest.mark.skipif(check, reason="Redis enterprise") + return pytest.mark.skipif(check, reason="Not running in redis enterprise") def _get_client(cls, request, single_connection_client=True, flushdb=True, diff --git a/tests/test_json.py b/tests/test_json.py index 092d301b2d..187bfe2289 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -275,7 +275,6 @@ def test_objlen(client): assert len(obj) == client.json().objlen("obj") -@pytest.mark.pipeline @pytest.mark.redismod def test_json_commands_in_pipeline(client): p = client.json().pipeline() @@ -290,8 +289,9 @@ def test_json_commands_in_pipeline(client): client.flushdb() p = client.json().pipeline() d = {"hello": "world", "oh": "snap"} - p.jsonset("foo", Path.rootPath(), d) - p.jsonget("foo") + with pytest.deprecated_call(): + p.jsonset("foo", Path.rootPath(), d) + p.jsonget("foo") p.exists("notarealkey") p.delete("foo") assert [True, d, 0, 1] == p.execute() @@ -463,14 +463,18 @@ def test_numby_commands_dollar(client): client.json().set("doc1", "$", {"a": "b", "b": [ {"a": 2}, {"a": 5.0}, {"a": "c"}]}) - assert client.json().nummultby("doc1", "$..a", 2) == \ - [None, 4, 10, None] - assert client.json().nummultby("doc1", "$..a", 2.5) == \ - [None, 10.0, 25.0, None] + # test list + with pytest.deprecated_call(): + assert client.json().nummultby("doc1", "$..a", 2) == \ + [None, 4, 10, None] + assert client.json().nummultby("doc1", "$..a", 2.5) == \ + [None, 10.0, 25.0, None] + # Test single - assert client.json().nummultby("doc1", "$.b[1].a", 2) == [50.0] - assert client.json().nummultby("doc1", "$.b[2].a", 2) == [None] - assert client.json().nummultby("doc1", "$.b[1].a", 3) == [150.0] + with pytest.deprecated_call(): + assert client.json().nummultby("doc1", "$.b[1].a", 2) == [50.0] + assert client.json().nummultby("doc1", "$.b[2].a", 2) == [None] + assert client.json().nummultby("doc1", "$.b[1].a", 3) == [150.0] # test missing keys with pytest.raises(exceptions.ResponseError): @@ -485,7 +489,9 @@ def test_numby_commands_dollar(client): # Test legacy NUMMULTBY client.json().set("doc1", "$", {"a": "b", "b": [ {"a": 2}, {"a": 5.0}, {"a": "c"}]}) - client.json().nummultby("doc1", ".b[0].a", 3) == 6 + + with pytest.deprecated_call(): + client.json().nummultby("doc1", ".b[0].a", 3) == 6 @pytest.mark.redismod @@ -824,9 +830,11 @@ def test_objkeys_dollar(client): # Test missing key assert client.json().objkeys("non_existing_doc", "..a") is None - # Test missing key + # Test non existing doc with pytest.raises(exceptions.ResponseError): - client.json().objkeys("doc1", "$.nowhere") + assert client.json().objkeys("non_existing_doc", "$..a") == [] + + assert client.json().objkeys("doc1", "$..nowhere") == [] @pytest.mark.redismod @@ -845,12 +853,11 @@ def test_objlen_dollar(client): # Test single assert client.json().objlen("doc1", "$.nested1.a") == [2] - # Test missing key - assert client.json().objlen("non_existing_doc", "$..a") is None - - # Test missing path + # Test missing key, and path with pytest.raises(exceptions.ResponseError): - client.json().objlen("doc1", "$.nowhere") + client.json().objlen("non_existing_doc", "$..a") + + assert client.json().objlen("doc1", "$.nowhere") == [] # Test legacy assert client.json().objlen("doc1", ".*.a") == 2 @@ -862,8 +869,8 @@ def test_objlen_dollar(client): assert client.json().objlen("non_existing_doc", "..a") is None # Test missing path - with pytest.raises(exceptions.ResponseError): - client.json().objlen("doc1", ".nowhere") + # with pytest.raises(exceptions.ResponseError): + client.json().objlen("doc1", ".nowhere") @pytest.mark.redismod @@ -1143,11 +1150,11 @@ def test_resp_dollar(client): ] # Test missing path - with pytest.raises(exceptions.ResponseError): - client.json().resp("doc1", "$.nowhere") + client.json().resp("doc1", "$.nowhere") # Test missing key - assert client.json().resp("non_existing_doc", "$..a") is None + # with pytest.raises(exceptions.ResponseError): + client.json().resp("non_existing_doc", "$..a") @pytest.mark.redismod diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 99c60838f2..c0fb09e226 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -565,7 +565,6 @@ def test_query_index(client): @pytest.mark.redismod -@pytest.mark.pipeline def test_pipeline(client): pipeline = client.ts().pipeline() pipeline.create("with_pipeline") From 8991370332e818cf9886cec6adc1858189b04bd6 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 25 Nov 2021 13:18:25 +0100 Subject: [PATCH 0266/1164] COMMAND GETKEYS support (#1738) --- redis/commands/core.py | 3 +++ tests/test_commands.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/redis/commands/core.py b/redis/commands/core.py index ad45adcfad..948655bbea 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -3989,6 +3989,9 @@ def command_info(self): def command_count(self): return self.execute_command('COMMAND COUNT') + def command_getkeys(self, *args): + return self.execute_command('COMMAND GETKEYS', *args) + def command(self): return self.execute_command('COMMAND') diff --git a/tests/test_commands.py b/tests/test_commands.py index 780a1bf443..b4d371a671 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3796,6 +3796,15 @@ def test_command_count(self, r): assert isinstance(res, int) assert res >= 100 + @skip_if_server_version_lt('2.8.13') + def test_command_getkeys(self, r): + res = r.command_getkeys('MSET', 'a', 'b', 'c', 'd', 'e', 'f') + assert res == [b'a', b'c', b'e'] + res = r.command_getkeys('EVAL', '"not consulted"', + '3', 'key1', 'key2', 'key3', + 'arg1', 'arg2', 'arg3', 'argN') + assert res == [b'key1', b'key2', b'key3'] + @skip_if_server_version_lt('2.8.13') def test_command(self, r): res = r.command() From d7b56103ed4d0ba9c05c74ca5580c72fcb70c09c Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 14:18:33 +0200 Subject: [PATCH 0267/1164] Adding support for non-decodable commands (#1731) --- redis/client.py | 8 +++++++- redis/commands/core.py | 5 ++++- redis/connection.py | 15 +++++++++------ redis/sentinel.py | 4 ++-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/redis/client.py b/redis/client.py index 143ed88731..30a5c3c661 100755 --- a/redis/client.py +++ b/redis/client.py @@ -27,6 +27,9 @@ SYM_EMPTY = b'' EMPTY_RESPONSE = 'EMPTY_RESPONSE' +# some responses (ie. dump) are binary, and just meant to never be decoded +NEVER_DECODE = 'NEVER_DECODE' + def timestamp_to_datetime(response): "Converts a unix timestamp to a Python datetime object" @@ -1106,7 +1109,10 @@ def execute_command(self, *args, **options): def parse_response(self, connection, command_name, **options): "Parses a response from the Redis server" try: - response = connection.read_response() + if NEVER_DECODE in options: + response = connection.read_response(disable_decoding=True) + else: + response = connection.read_response() except ResponseError: if EMPTY_RESPONSE in options: return options[EMPTY_RESPONSE] diff --git a/redis/commands/core.py b/redis/commands/core.py index 948655bbea..64e3b6d37e 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1008,7 +1008,10 @@ def dump(self, name): For more information check https://redis.io/commands/dump """ - return self.execute_command('DUMP', name) + from redis.client import NEVER_DECODE + options = {} + options[NEVER_DECODE] = [] + return self.execute_command('DUMP', name, **options) def exists(self, *names): """ diff --git a/redis/connection.py b/redis/connection.py index eac9db358f..2f91fae890 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -315,7 +315,7 @@ def on_disconnect(self): def can_read(self, timeout): return self._buffer and self._buffer.can_read(timeout) - def read_response(self): + def read_response(self, disable_decoding=False): raw = self._buffer.readline() if not raw: raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) @@ -355,8 +355,9 @@ def read_response(self): length = int(response) if length == -1: return None - response = [self.read_response() for i in range(length)] - if isinstance(response, bytes): + response = [self.read_response(disable_decoding=disable_decoding) + for i in range(length)] + if isinstance(response, bytes) and disable_decoding is False: response = self.encoder.decode(response) return response @@ -450,7 +451,7 @@ def read_from_socket(self, timeout=SENTINEL, raise_on_timeout=True): if custom_timeout: sock.settimeout(self._socket_timeout) - def read_response(self): + def read_response(self, disable_decoding=False): if not self._reader: raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) @@ -758,10 +759,12 @@ def can_read(self, timeout=0): self.connect() return self._parser.can_read(timeout) - def read_response(self): + def read_response(self, disable_decoding=False): """Read the response from a previously sent command""" try: - response = self._parser.read_response() + response = self._parser.read_response( + disable_decoding=disable_decoding + ) except socket.timeout: self.disconnect() raise TimeoutError("Timeout reading from %s:%s" % diff --git a/redis/sentinel.py b/redis/sentinel.py index 17dd75bf46..3efd58fa39 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -51,9 +51,9 @@ def connect(self): continue raise SlaveNotFoundError # Never be here - def read_response(self): + def read_response(self, disable_decoding=False): try: - return super().read_response() + return super().read_response(disable_decoding=disable_decoding) except ReadOnlyError: if self.connection_pool.is_master: # When talking to a master, a ReadOnlyError when likely From 20c5f0fa4676c4f0fde778dae81c3f96078348b5 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 15:37:58 +0200 Subject: [PATCH 0268/1164] Fixing COMMAND GETKEYS tests (#1750) --- .gitignore | 2 ++ tasks.py | 6 +++--- tests/test_commands.py | 5 +++-- tox.ini | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 05c384652c..08138d7c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ env venv coverage.xml .venv +*.xml +.coverage* diff --git a/tasks.py b/tasks.py index 138ca69409..137d1771bd 100644 --- a/tasks.py +++ b/tasks.py @@ -41,15 +41,15 @@ def tests(c): with and without hiredis. """ print("Starting Redis tests") - run("tox -e '{redis,cluster}'-'{plain,hiredis}'") + run("tox -e '{standalone,cluster}'-'{plain,hiredis}'") @task -def redis_tests(c): +def standalone_tests(c): """Run all Redis tests against the current python, with and without hiredis.""" print("Starting Redis tests") - run("tox -e redis-'{hiredis}'") + run("tox -e standalone-'{hiredis}'") @task diff --git a/tests/test_commands.py b/tests/test_commands.py index b4d371a671..f526ae5dd6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3796,14 +3796,15 @@ def test_command_count(self, r): assert isinstance(res, int) assert res >= 100 + @pytest.mark.onlynoncluster @skip_if_server_version_lt('2.8.13') def test_command_getkeys(self, r): res = r.command_getkeys('MSET', 'a', 'b', 'c', 'd', 'e', 'f') - assert res == [b'a', b'c', b'e'] + assert res == ['a', 'c', 'e'] res = r.command_getkeys('EVAL', '"not consulted"', '3', 'key1', 'key2', 'key3', 'arg1', 'arg2', 'arg3', 'argN') - assert res == [b'key1', b'key2', b'key3'] + assert res == ['key1', 'key2', 'key3'] @skip_if_server_version_lt('2.8.13') def test_command(self, r): diff --git a/tox.ini b/tox.ini index c68dab18ea..0a94db823b 100644 --- a/tox.ini +++ b/tox.ini @@ -3,12 +3,12 @@ addopts = -s markers = redismod: run only the redis module tests onlycluster: marks tests to be run only with cluster mode redis - onlynoncluster: marks tests to be run only with non-cluster redis + onlynoncluster: marks tests to be run only with standalone redis [tox] minversion = 3.2.0 requires = tox-docker -envlist = {redis,cluster}-{plain,hiredis}-{py35,py36,py37,py38,py39,pypy3},linters +envlist = {standalone,cluster}-{plain,hiredis}-{py35,py36,py37,py38,py39,pypy3},linters [docker:master] name = master From 3de2e6b6b1bc061d875d36a6f40598453ce85c58 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 25 Nov 2021 14:38:21 +0100 Subject: [PATCH 0269/1164] Improve code coverage for aggregation tests (#1713) --- redis/commands/search/aggregation.py | 6 - tests/test_search.py | 322 ++++++++++++++++++++++----- 2 files changed, 264 insertions(+), 64 deletions(-) diff --git a/redis/commands/search/aggregation.py b/redis/commands/search/aggregation.py index b391d1f55b..3d71329c44 100644 --- a/redis/commands/search/aggregation.py +++ b/redis/commands/search/aggregation.py @@ -345,12 +345,6 @@ def cursor(self, count=0, max_idle=0.0): self._cursor = args return self - def _limit_2_args(self, limit): - if limit[1]: - return ["LIMIT"] + [str(x) for x in limit] - else: - return [] - def build_args(self): # @foo:bar ... ret = [self._query] diff --git a/tests/test_search.py b/tests/test_search.py index d1fc75fb9d..0cba3b74f6 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -82,8 +82,8 @@ def createIndex(client, num_docs=100, definition=None): try: client.create_index( (TextField("play", weight=5.0), - TextField("txt"), - NumericField("chapter")), + TextField("txt"), + NumericField("chapter")), definition=definition, ) except redis.ResponseError: @@ -320,8 +320,8 @@ def test_stopwords(client): def test_filters(client): client.ft().create_index( (TextField("txt"), - NumericField("num"), - GeoField("loc")) + NumericField("num"), + GeoField("loc")) ) client.ft().add_document( "doc1", @@ -379,7 +379,7 @@ def test_payloads_with_no_content(client): def test_sort_by(client): client.ft().create_index( (TextField("txt"), - NumericField("num", sortable=True)) + NumericField("num", sortable=True)) ) client.ft().add_document("doc1", txt="foo bar", num=1) client.ft().add_document("doc2", txt="foo baz", num=2) @@ -424,7 +424,7 @@ def test_example(client): # Creating the index definition and schema client.ft().create_index( (TextField("title", weight=5.0), - TextField("body")) + TextField("body")) ) # Indexing a document @@ -552,8 +552,8 @@ def test_no_index(client): def test_partial(client): client.ft().create_index( (TextField("f1"), - TextField("f2"), - TextField("f3")) + TextField("f2"), + TextField("f3")) ) client.ft().add_document("doc1", f1="f1_val", f2="f2_val") client.ft().add_document("doc2", f1="f1_val", f2="f2_val") @@ -574,8 +574,8 @@ def test_partial(client): def test_no_create(client): client.ft().create_index( (TextField("f1"), - TextField("f2"), - TextField("f3")) + TextField("f2"), + TextField("f3")) ) client.ft().add_document("doc1", f1="f1_val", f2="f2_val") client.ft().add_document("doc2", f1="f1_val", f2="f2_val") @@ -604,8 +604,8 @@ def test_no_create(client): def test_explain(client): client.ft().create_index( (TextField("f1"), - TextField("f2"), - TextField("f3")) + TextField("f2"), + TextField("f3")) ) res = client.ft().explain("@f3:f3_val @f2:f2_val @f1:f1_val") assert res @@ -629,8 +629,8 @@ def test_summarize(client): doc = sorted(client.ft().search(q).docs)[0] assert "Henry IV" == doc.play assert ( - "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa - == doc.txt + "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa + == doc.txt ) q = Query("king henry").paging(0, 1).summarize().highlight() @@ -638,8 +638,8 @@ def test_summarize(client): doc = sorted(client.ft().search(q).docs)[0] assert "Henry ... " == doc.play assert ( - "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa - == doc.txt + "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa + == doc.txt ) @@ -812,10 +812,10 @@ def test_spell_check(client): res = client.ft().spellcheck("lorm", include="dict") assert len(res["lorm"]) == 3 assert ( - res["lorm"][0]["suggestion"], - res["lorm"][1]["suggestion"], - res["lorm"][2]["suggestion"], - ) == ("lorem", "lore", "lorm") + res["lorm"][0]["suggestion"], + res["lorm"][1]["suggestion"], + res["lorm"][2]["suggestion"], + ) == ("lorem", "lore", "lorm") assert (res["lorm"][0]["score"], res["lorm"][1]["score"]) == ("0.5", "0") # test spellcheck exclude @@ -873,7 +873,7 @@ def test_scorer(client): ) client.ft().add_document( "doc2", - description="Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.", # noqa + description="Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.", # noqa ) # default scorer is TFIDF @@ -930,7 +930,7 @@ def test_config(client): @pytest.mark.redismod -def test_aggregations(client): +def test_aggregations_groupby(client): # Creating the index definition and schema client.ft().create_index( ( @@ -967,36 +967,242 @@ def test_aggregations(client): req = aggregations.AggregateRequest("redis").group_by( "@parent", reducers.count(), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "3" + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.count_distinct("@title"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "3" + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.count_distinctish("@title"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "3" + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.sum("@random_num"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "21" # 10+8+3 + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.min("@random_num"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "3" # min(10,8,3) + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.max("@random_num"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "10" # max(10,8,3) + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.avg("@random_num"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "7" # (10+3+8)/3 + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.stddev("random_num"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "3.60555127546" + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.quantile("@random_num", 0.5), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == "10" + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", reducers.tolist("@title"), - reducers.first_value("@title"), - reducers.random_sample("@title", 2), ) + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[3] == ["RediSearch", "RedisAI", "RedisJson"] + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", + reducers.first_value("@title").alias("first"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res == ['parent', 'redis', 'first', 'RediSearch'] + + req = aggregations.AggregateRequest("redis").group_by( + "@parent", + reducers.random_sample("@title", 2).alias("random"), + ) + + res = client.ft().aggregate(req).rows[0] + assert res[1] == "redis" + assert res[2] == "random" + assert len(res[3]) == 2 + assert res[3][0] in ["RediSearch", "RedisAI", "RedisJson"] + + +@pytest.mark.redismod +def test_aggregations_sort_by_and_limit(client): + client.ft().create_index( + ( + TextField("t1"), + TextField("t2"), + ) + ) + + client.ft().client.hset("doc1", mapping={'t1': 'a', 't2': 'b'}) + client.ft().client.hset("doc2", mapping={'t1': 'b', 't2': 'a'}) + + # test sort_by using SortDirection + req = aggregations.AggregateRequest("*") \ + .sort_by(aggregations.Asc("@t2"), aggregations.Desc("@t1")) + res = client.ft().aggregate(req) + assert res.rows[0] == ['t2', 'a', 't1', 'b'] + assert res.rows[1] == ['t2', 'b', 't1', 'a'] + + # test sort_by without SortDirection + req = aggregations.AggregateRequest("*") \ + .sort_by("@t1") + res = client.ft().aggregate(req) + assert res.rows[0] == ['t1', 'a'] + assert res.rows[1] == ['t1', 'b'] + + # test sort_by with max + req = aggregations.AggregateRequest("*") \ + .sort_by("@t1", max=1) + res = client.ft().aggregate(req) + assert len(res.rows) == 1 + + # test limit + req = aggregations.AggregateRequest("*") \ + .sort_by("@t1").limit(1, 1) res = client.ft().aggregate(req) + assert len(res.rows) == 1 + assert res.rows[0] == ['t1', 'b'] - res = res.rows[0] - assert len(res) == 26 - assert "redis" == res[1] - assert "3" == res[3] - assert "3" == res[5] - assert "3" == res[7] - assert "21" == res[9] - assert "3" == res[11] - assert "10" == res[13] - assert "7" == res[15] - assert "3.60555127546" == res[17] - assert "10" == res[19] - assert ["RediSearch", "RedisAI", "RedisJson"] == res[21] - assert "RediSearch" == res[23] - assert 2 == len(res[25]) + +@pytest.mark.redismod +def test_aggregations_load(client): + client.ft().create_index( + ( + TextField("t1"), + TextField("t2"), + ) + ) + + client.ft().client.hset("doc1", mapping={'t1': 'hello', 't2': 'world'}) + + # load t1 + req = aggregations.AggregateRequest("*").load("t1") + res = client.ft().aggregate(req) + assert res.rows[0] == ['t1', 'hello'] + + # load t2 + req = aggregations.AggregateRequest("*").load("t2") + res = client.ft().aggregate(req) + assert res.rows[0] == ['t2', 'world'] + + +@pytest.mark.redismod +def test_aggregations_apply(client): + client.ft().create_index( + ( + TextField("PrimaryKey", sortable=True), + NumericField("CreatedDateTimeUTC", sortable=True), + ) + ) + + client.ft().client.hset( + "doc1", + mapping={ + 'PrimaryKey': '9::362330', + 'CreatedDateTimeUTC': '637387878524969984' + } + ) + client.ft().client.hset( + "doc2", + mapping={ + 'PrimaryKey': '9::362329', + 'CreatedDateTimeUTC': '637387875859270016' + } + ) + + req = aggregations.AggregateRequest("*") \ + .apply(CreatedDateTimeUTC='@CreatedDateTimeUTC * 10') + res = client.ft().aggregate(req) + assert res.rows[0] == ['CreatedDateTimeUTC', '6373878785249699840'] + assert res.rows[1] == ['CreatedDateTimeUTC', '6373878758592700416'] + + +@pytest.mark.redismod +def test_aggregations_filter(client): + client.ft().create_index( + ( + TextField("name", sortable=True), + NumericField("age", sortable=True), + ) + ) + + client.ft().client.hset( + "doc1", + mapping={ + 'name': 'bar', + 'age': '25' + } + ) + client.ft().client.hset( + "doc2", + mapping={ + 'name': 'foo', + 'age': '19' + } + ) + + req = aggregations.AggregateRequest("*") \ + .filter("@name=='foo' && @age < 20") + res = client.ft().aggregate(req) + assert len(res.rows) == 1 + assert res.rows[0] == ['name', 'foo', 'age', '19'] + + req = aggregations.AggregateRequest("*") \ + .filter("@age > 15").sort_by("@age") + res = client.ft().aggregate(req) + assert len(res.rows) == 2 + assert res.rows[0] == ['age', '19'] + assert res.rows[1] == ['age', '25'] @pytest.mark.redismod @@ -1020,25 +1226,25 @@ def test_index_definition(client): ) assert [ - "ON", - "JSON", - "PREFIX", - 2, - "hset:", - "henry", - "FILTER", - "@f1==32", - "LANGUAGE_FIELD", - "play", - "LANGUAGE", - "English", - "SCORE_FIELD", - "chapter", - "SCORE", - 0.5, - "PAYLOAD_FIELD", - "txt", - ] == definition.args + "ON", + "JSON", + "PREFIX", + 2, + "hset:", + "henry", + "FILTER", + "@f1==32", + "LANGUAGE_FIELD", + "play", + "LANGUAGE", + "English", + "SCORE_FIELD", + "chapter", + "SCORE", + 0.5, + "PAYLOAD_FIELD", + "txt", + ] == definition.args createIndex(client.ft(), num_docs=500, definition=definition) From 393cd6280c6fb5394cc512ae15617236ecddac2e Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 25 Nov 2021 14:45:19 +0100 Subject: [PATCH 0270/1164] Support RediSearch FT.PROFILE command (#1727) --- redis/commands/helpers.py | 41 ++++++++++++++++++++++-- redis/commands/search/commands.py | 53 ++++++++++++++++++++++++++++--- tests/test_helpers.py | 26 ++++++++++++++- tests/test_search.py | 43 +++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 7 deletions(-) diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index 46eb83d603..5e8ff49d9b 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -35,9 +35,12 @@ def delist(x): def parse_to_list(response): - """Optimistally parse the response to a list. - """ + """Optimistically parse the response to a list.""" res = [] + + if response is None: + return res + for item in response: try: res.append(int(item)) @@ -51,6 +54,40 @@ def parse_to_list(response): return res +def parse_list_to_dict(response): + res = {} + for i in range(0, len(response), 2): + if isinstance(response[i], list): + res['Child iterators'].append(parse_list_to_dict(response[i])) + elif isinstance(response[i+1], list): + res['Child iterators'] = [parse_list_to_dict(response[i+1])] + else: + try: + res[response[i]] = float(response[i+1]) + except (TypeError, ValueError): + res[response[i]] = response[i+1] + return res + + +def parse_to_dict(response): + if response is None: + return {} + + res = {} + for det in response: + if isinstance(det[1], list): + res[det[0]] = parse_list_to_dict(det[1]) + else: + try: # try to set the attribute. may be provided without value + try: # try to convert the value to float + res[det[0]] = float(det[1]) + except (TypeError, ValueError): + res[det[0]] = det[1] + except IndexError: + pass + return res + + def random_string(length=10): """ Returns a random N character long string. diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py index 0cee2ade4e..ed58255fec 100644 --- a/redis/commands/search/commands.py +++ b/redis/commands/search/commands.py @@ -7,6 +7,7 @@ from ._util import to_string from .aggregation import AggregateRequest, AggregateResult, Cursor from .suggestion import SuggestionParser +from ..helpers import parse_to_dict NUMERIC = "NUMERIC" @@ -20,6 +21,7 @@ EXPLAINCLI_CMD = "FT.EXPLAINCLI" DEL_CMD = "FT.DEL" AGGREGATE_CMD = "FT.AGGREGATE" +PROFILE_CMD = "FT.PROFILE" CURSOR_CMD = "FT.CURSOR" SPELLCHECK_CMD = "FT.SPELLCHECK" DICT_ADD_CMD = "FT.DICTADD" @@ -382,11 +384,11 @@ def explain_cli(self, query): # noqa def aggregate(self, query): """ - Issue an aggregation query + Issue an aggregation query. ### Parameters - **query**: This can be either an `AggeregateRequest`, or a `Cursor` + **query**: This can be either an `AggregateRequest`, or a `Cursor` An `AggregateResult` object is returned. You can access the rows from its `rows` property, which will always yield the rows of the result. @@ -401,6 +403,9 @@ def aggregate(self, query): raise ValueError("Bad query", query) raw = self.execute_command(*cmd) + return self._get_AggregateResult(raw, query, has_cursor) + + def _get_AggregateResult(self, raw, query, has_cursor): if has_cursor: if isinstance(query, Cursor): query.cid = raw[1] @@ -418,8 +423,48 @@ def aggregate(self, query): schema = None rows = raw[1:] - res = AggregateResult(rows, cursor, schema) - return res + return AggregateResult(rows, cursor, schema) + + def profile(self, query, limited=False): + """ + Performs a search or aggregate command and collects performance + information. + + ### Parameters + + **query**: This can be either an `AggregateRequest`, `Query` or + string. + **limited**: If set to True, removes details of reader iterator. + + """ + st = time.time() + cmd = [PROFILE_CMD, self.index_name, ""] + if limited: + cmd.append("LIMITED") + cmd.append('QUERY') + + if isinstance(query, AggregateRequest): + cmd[2] = "AGGREGATE" + cmd += query.build_args() + elif isinstance(query, Query): + cmd[2] = "SEARCH" + cmd += query.get_args() + else: + raise ValueError("Must provide AggregateRequest object or " + "Query object.") + + res = self.execute_command(*cmd) + + if isinstance(query, AggregateRequest): + result = self._get_AggregateResult(res[0], query, query._cursor) + else: + result = Result(res[0], + not query._no_content, + duration=(time.time() - st) * 1000.0, + has_payload=query._with_payloads, + with_scores=query._with_scores,) + + return result, parse_to_dict(res[1]) def spellcheck(self, query, distance=None, include=None, exclude=None): """ diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 467e00c1fd..402eccf0a2 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -5,7 +5,8 @@ nativestr, parse_to_list, quote_string, - random_string + random_string, + parse_to_dict ) @@ -19,11 +20,34 @@ def test_list_or_args(): def test_parse_to_list(): + assert parse_to_list(None) == [] r = ["hello", b"my name", "45", "555.55", "is simon!", None] assert parse_to_list(r) == \ ["hello", "my name", 45, 555.55, "is simon!", None] +def test_parse_to_dict(): + assert parse_to_dict(None) == {} + r = [['Some number', '1.0345'], + ['Some string', 'hello'], + ['Child iterators', + ['Time', '0.2089', 'Counter', 3, 'Child iterators', + ['Type', 'bar', 'Time', '0.0729', 'Counter', 3], + ['Type', 'barbar', 'Time', '0.058', 'Counter', 3]]]] + assert parse_to_dict(r) == { + 'Child iterators': { + 'Child iterators': [ + {'Counter': 3.0, 'Time': 0.0729, 'Type': 'bar'}, + {'Counter': 3.0, 'Time': 0.058, 'Type': 'barbar'} + ], + 'Counter': 3.0, + 'Time': 0.2089 + }, + 'Some number': 1.0345, + 'Some string': 'hello' + } + + def test_nativestr(): assert nativestr('teststr') == 'teststr' assert nativestr(b'teststr') == 'teststr' diff --git a/tests/test_search.py b/tests/test_search.py index 0cba3b74f6..b65ac8dcbf 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1519,3 +1519,46 @@ def test_json_with_jsonpath(client): assert res.docs[0].id == "doc:1" with pytest.raises(Exception): res.docs[0].name_unsupported + + +@pytest.mark.redismod +def test_profile(client): + client.ft().create_index((TextField('t'),)) + client.ft().client.hset('1', 't', 'hello') + client.ft().client.hset('2', 't', 'world') + + # check using Query + q = Query('hello|world').no_content() + res, det = client.ft().profile(q) + assert det['Iterators profile']['Counter'] == 2.0 + assert len(det['Iterators profile']['Child iterators']) == 2 + assert det['Iterators profile']['Type'] == 'UNION' + assert det['Parsing time'] < 0.3 + assert len(res.docs) == 2 # check also the search result + + # check using AggregateRequest + req = aggregations.AggregateRequest("*").load("t")\ + .apply(prefix="startswith(@t, 'hel')") + res, det = client.ft().profile(req) + assert det['Iterators profile']['Counter'] == 2.0 + assert det['Iterators profile']['Type'] == 'WILDCARD' + assert det['Parsing time'] < 0.3 + assert len(res.rows) == 2 # check also the search result + + +@pytest.mark.redismod +def test_profile_limited(client): + client.ft().create_index((TextField('t'),)) + client.ft().client.hset('1', 't', 'hello') + client.ft().client.hset('2', 't', 'hell') + client.ft().client.hset('3', 't', 'help') + client.ft().client.hset('4', 't', 'helowa') + + q = Query('%hell% hel*') + res, det = client.ft().profile(q, limited=True) + assert det['Iterators profile']['Child iterators'][0]['Child iterators'] \ + == 'The number of iterators in the union is 3' + assert det['Iterators profile']['Child iterators'][1]['Child iterators'] \ + == 'The number of iterators in the union is 4' + assert det['Iterators profile']['Type'] == 'INTERSECT' + assert len(res.docs) == 3 # check also the search result From f4519f3b7f1f7314b5d342be989df1c4365954b9 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 16:03:30 +0200 Subject: [PATCH 0271/1164] Splitting documentation for read the docs (#1743) --- README.md | 2 +- docs/backoff.rst | 5 ++ docs/conf.py | 22 ++++++-- docs/connections.rst | 12 +++++ docs/exceptions.rst | 7 +++ docs/genindex.rst | 2 + docs/index.rst | 78 +++++++++++++++++++-------- docs/lock.rst | 5 ++ docs/redis_core_commands.rst | 14 +++++ docs/redismodules.rst | 19 +++++++ docs/requirements.txt | 1 + docs/retry.rst | 5 ++ docs/sentinel_commands.rst | 20 +++++++ redis/commands/timeseries/commands.py | 60 ++++++++++----------- tasks.py | 6 +++ tox.ini | 11 +++- 16 files changed, 209 insertions(+), 60 deletions(-) create mode 100644 docs/backoff.rst create mode 100644 docs/connections.rst create mode 100644 docs/exceptions.rst create mode 100644 docs/genindex.rst create mode 100644 docs/lock.rst create mode 100644 docs/redis_core_commands.rst create mode 100644 docs/redismodules.rst create mode 100644 docs/retry.rst create mode 100644 docs/sentinel_commands.rst diff --git a/README.md b/README.md index 6e7d964b2b..d068c68f14 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The Python interface to the Redis key-value store. [![codecov](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg?token=yenl5fzxxr)](https://codecov.io/gh/redis/redis-py) [![Total alerts](https://img.shields.io/lgtm/alerts/g/redis/redis-py.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/redis/redis-py/alerts/) -[Installation](##installation) | [Contributing](##contributing) | [Getting Started](##getting-started) | [Connecting To Redis](##connecting-to-redis) +[Installation](#installation) | [Contributing](#contributing) | [Getting Started](#getting-started) | [Connecting To Redis](#connecting-to-redis) --------------------------------------------- diff --git a/docs/backoff.rst b/docs/backoff.rst new file mode 100644 index 0000000000..e640b5682e --- /dev/null +++ b/docs/backoff.rst @@ -0,0 +1,5 @@ +Backoff +############# + +.. automodule:: redis.backoff + :members: \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index f497e3da15..8520969d24 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,7 +29,8 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.doctest", - "sphinx.ext.viewcode" + "sphinx.ext.viewcode", + "sphinx.ext.autosectionlabel", ] # Add any paths that contain templates here, relative to this directory. @@ -53,10 +54,11 @@ # built documents. # # The short X.Y version. -version = "4.0" +import redis +version = '.'.join(redis.__version__.split(".")[0:2]) # The full version, including alpha/beta/rc tags. -release = "4.0.0" +release = redis.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -93,17 +95,27 @@ # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] +nitpicky = True + # -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "default" +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -# html_theme_options = {} +html_theme_options = { + 'display_version': True, + 'prev_next_buttons_location': 'bottom', + 'style_external_links': False, + # Toc options + 'collapse_navigation': True, + 'sticky_navigation': True, + 'navigation_depth': 4, +} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] diff --git a/docs/connections.rst b/docs/connections.rst new file mode 100644 index 0000000000..973821bb07 --- /dev/null +++ b/docs/connections.rst @@ -0,0 +1,12 @@ +Connecting to Redis +##################### + +Generic Client +************** +.. autoclass:: redis.client.Redis + :members: + +Connection Pools +***************** +.. autoclass:: redis.connection.ConnectionPool + :members: \ No newline at end of file diff --git a/docs/exceptions.rst b/docs/exceptions.rst new file mode 100644 index 0000000000..b8aeb33e49 --- /dev/null +++ b/docs/exceptions.rst @@ -0,0 +1,7 @@ + + +Exceptions +########## + +.. automodule:: redis.exceptions + :members: \ No newline at end of file diff --git a/docs/genindex.rst b/docs/genindex.rst new file mode 100644 index 0000000000..c1f8355121 --- /dev/null +++ b/docs/genindex.rst @@ -0,0 +1,2 @@ +Module Index +============ \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 8af5385da3..392acadf5b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,37 +6,71 @@ Welcome to redis-py's documentation! ==================================== -Indices and tables ------------------- +Getting Started +**************** -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +`redis-py `_ requires a running Redis server, and Python 3.6+. See the `Redis +quickstart `_ for Redis installation instructions. -Contents: ---------- +redis-py can be installed using pip via ``pip install redis``. + + +Quickly connecting to redis +************ + +There are two quick ways to connect to Redis. + +Assuming you run Redis on localhost:6379 (the default):: + import redis + r = redis.Redis() + r.ping() + +Running redis on foo.bar.com, port 12345:: + import redis + r = redis.Redis(host='foo.bar.com', port=12345) + r.ping() + +Another example with foo.bar.com, port 12345:: + import redis + r = redis.from_url('redis://foo.bar.com:12345') + r.ping() + +After that, you probably want to `run redis commands `_. .. toctree:: - :maxdepth: 2 + :hidden: + + genindex -.. automodule:: redis - :members: +Redis Command Functions +*********************** +.. toctree:: + :maxdepth: 2 -.. automodule:: redis.backoff - :members: + redis_core_commands + sentinel_commands + redismodules -.. automodule:: redis.connection - :members: +Module Documentation +******************** +.. toctree:: + :maxdepth: 1 -.. automodule:: redis.commands - :members: + backoff + connections + exceptions + lock + retry -.. automodule:: redis.exceptions - :members: +Contributing +************* -.. automodule:: redis.lock - :members: +- `How to contribute `_ +- `Issue Tracker `_ +- `Source Code `_ +- `Release History `_ -.. automodule:: redis.sentinel - :members: +License +******* +This projectis licensed under the `MIT license `_. \ No newline at end of file diff --git a/docs/lock.rst b/docs/lock.rst new file mode 100644 index 0000000000..cce0867a91 --- /dev/null +++ b/docs/lock.rst @@ -0,0 +1,5 @@ +Lock +######### + +.. automodule:: redis.lock + :members: \ No newline at end of file diff --git a/docs/redis_core_commands.rst b/docs/redis_core_commands.rst new file mode 100644 index 0000000000..edfd7fe939 --- /dev/null +++ b/docs/redis_core_commands.rst @@ -0,0 +1,14 @@ +Redis Core Commands +#################### + +The following functions can be used to replicate their equivalent `Redis command `_. Generally they can be used as functions on your redis connection. For the simplest example, see below: + +Getting and settings data in redis:: + + import redis + r = redis.Redis(decode_responses=True) + r.set('mykey', 'thevalueofmykey') + r.get('mykey') + +.. autoclass:: redis.commands.core.CoreCommands + :members: \ No newline at end of file diff --git a/docs/redismodules.rst b/docs/redismodules.rst new file mode 100644 index 0000000000..da8c36b7b4 --- /dev/null +++ b/docs/redismodules.rst @@ -0,0 +1,19 @@ +Redis Modules Commands +###################### + +Accessing redis module commands requires the installation of the supported `Redis module `_. For a quick start with redis modules, try the `Redismod docker `_. + +RedisTimeSeries Commands +************************ +.. automodule:: redis.commands.timeseries.commands + :members: TimeSeriesCommands + +RedisJSON Commands +****************** +.. automodule:: redis.commands.json.commands + :members: JSONCommands + +RediSearch Commands +******************* +.. automodule:: redis.commands.search.commands + :members: SearchCommands \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 2e1c4fbdce..6dc905f6b3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ sphinx<2 docutils<0.18 +sphinx-rtd-theme diff --git a/docs/retry.rst b/docs/retry.rst new file mode 100644 index 0000000000..2b4f22c2f6 --- /dev/null +++ b/docs/retry.rst @@ -0,0 +1,5 @@ +Retry Helpers +############# + +.. automodule:: redis.retry + :members: \ No newline at end of file diff --git a/docs/sentinel_commands.rst b/docs/sentinel_commands.rst new file mode 100644 index 0000000000..e5be11e85a --- /dev/null +++ b/docs/sentinel_commands.rst @@ -0,0 +1,20 @@ +Redis Sentinel Commands +======================= + +redis-py can be used together with `Redis +Sentinel `_ to discover Redis nodes. You +need to have at least one Sentinel daemon running in order to use +redis-py's Sentinel support. + +Connection example (assumes redis redis on the ports listed below): + + >>> from redis import Sentinel + >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) + >>> sentinel.discover_master('mymaster') + ('127.0.0.1', 6379) + >>> sentinel.discover_slaves('mymaster') + [('127.0.0.1', 6380)] + + +.. autoclass:: redis.commands.sentinel.SentinelCommands + :members: \ No newline at end of file diff --git a/redis/commands/timeseries/commands.py b/redis/commands/timeseries/commands.py index 3b9ee0f8b3..fcb535b50c 100644 --- a/redis/commands/timeseries/commands.py +++ b/redis/commands/timeseries/commands.py @@ -27,7 +27,7 @@ def create(self, key, **kwargs): """ Create a new time-series. For more information see - `TS.CREATE `_. # noqa + `TS.CREATE `_. Args: @@ -60,7 +60,7 @@ def create(self, key, **kwargs): - 'min': only override if the value is lower than the existing value. - 'max': only override if the value is higher than the existing value. When this is not set, the server-wide default will be used. - """ + """ # noqa retention_msecs = kwargs.get("retention_msecs", None) uncompressed = kwargs.get("uncompressed", False) labels = kwargs.get("labels", {}) @@ -79,10 +79,10 @@ def alter(self, key, **kwargs): """ Update the retention, labels of an existing key. For more information see - `TS.ALTER `_. # noqa + `TS.ALTER `_. The parameters are the same as TS.CREATE. - """ + """ # noqa retention_msecs = kwargs.get("retention_msecs", None) labels = kwargs.get("labels", {}) duplicate_policy = kwargs.get("duplicate_policy", None) @@ -97,7 +97,7 @@ def add(self, key, timestamp, value, **kwargs): """ Append (or create and append) a new sample to the series. For more information see - `TS.ADD `_. # noqa + `TS.ADD `_. Args: @@ -129,7 +129,7 @@ def add(self, key, timestamp, value, **kwargs): - 'min': only override if the value is lower than the existing value. - 'max': only override if the value is higher than the existing value. When this is not set, the server-wide default will be used. - """ + """ # noqa retention_msecs = kwargs.get("retention_msecs", None) uncompressed = kwargs.get("uncompressed", False) labels = kwargs.get("labels", {}) @@ -151,8 +151,8 @@ def madd(self, ktv_tuples): Expects a list of `tuples` as (`key`,`timestamp`, `value`). Return value is an array with timestamps of insertions. For more information see - `TS.MADD `_. # noqa - """ + `TS.MADD `_. + """ # noqa params = [] for ktv in ktv_tuples: for item in ktv: @@ -167,7 +167,7 @@ def incrby(self, key, value, **kwargs): This command can be used as a counter or gauge that automatically gets history as a time series. For more information see - `TS.INCRBY `_. # noqa + `TS.INCRBY `_. Args: @@ -189,7 +189,7 @@ def incrby(self, key, value, **kwargs): chunk_size: Each time-series uses chunks of memory of fixed size for time series samples. You can alter the default TSDB chunk size by passing the chunk_size argument (in Bytes). - """ + """ # noqa timestamp = kwargs.get("timestamp", None) retention_msecs = kwargs.get("retention_msecs", None) uncompressed = kwargs.get("uncompressed", False) @@ -211,7 +211,7 @@ def decrby(self, key, value, **kwargs): This command can be used as a counter or gauge that automatically gets history as a time series. For more information see - `TS.DECRBY `_. # noqa + `TS.DECRBY `_. Args: @@ -237,7 +237,7 @@ def decrby(self, key, value, **kwargs): chunk_size: Each time-series uses chunks of memory of fixed size for time series samples. You can alter the default TSDB chunk size by passing the chunk_size argument (in Bytes). - """ + """ # noqa timestamp = kwargs.get("timestamp", None) retention_msecs = kwargs.get("retention_msecs", None) uncompressed = kwargs.get("uncompressed", False) @@ -260,7 +260,7 @@ def delete(self, key, from_time, to_time): and end data points will also be deleted. Return the count for deleted items. For more information see - `TS.DEL `_. # noqa + `TS.DEL `_. Args: @@ -270,7 +270,7 @@ def delete(self, key, from_time, to_time): Start timestamp for the range deletion. to_time: End timestamp for the range deletion. - """ + """ # noqa return self.execute_command(DEL_CMD, key, from_time, to_time) def createrule( @@ -286,8 +286,8 @@ def createrule( [`avg`, `sum`, `min`, `max`, `range`, `count`, `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`] For more information see - `TS.CREATERULE `_. # noqa - """ + `TS.CREATERULE `_. + """ # noqa params = [source_key, dest_key] self._appendAggregation(params, aggregation_type, bucket_size_msec) @@ -297,8 +297,8 @@ def deleterule(self, source_key, dest_key): """ Delete a compaction rule. For more information see - `TS.DELETERULE `_. # noqa - """ + `TS.DELETERULE `_. + """ # noqa return self.execute_command(DELETERULE_CMD, source_key, dest_key) def __range_params( @@ -344,7 +344,7 @@ def range( """ Query a range in forward direction for a specific time-serie. For more information see - `TS.RANGE `_. # noqa + `TS.RANGE `_. Args: @@ -374,7 +374,7 @@ def range( by_min_value). align: Timestamp for alignment control for aggregation. - """ + """ # noqa params = self.__range_params( key, from_time, @@ -405,7 +405,7 @@ def revrange( """ Query a range in reverse direction for a specific time-series. For more information see - `TS.REVRANGE `_. # noqa + `TS.REVRANGE `_. **Note**: This command is only available since RedisTimeSeries >= v1.4 @@ -432,7 +432,7 @@ def revrange( Filter result by maximum value (must mention also filter_by_min_value). align: Timestamp for alignment control for aggregation. - """ + """ # noqa params = self.__range_params( key, from_time, @@ -501,7 +501,7 @@ def mrange( """ Query a range across multiple time-series by filters in forward direction. For more information see - `TS.MRANGE `_. # noqa + `TS.MRANGE `_. Args: @@ -544,7 +544,7 @@ def mrange( pair labels of a series. align: Timestamp for alignment control for aggregation. - """ + """ # noqa params = self.__mrange_params( aggregation_type, bucket_size_msec, @@ -584,7 +584,7 @@ def mrevrange( """ Query a range across multiple time-series by filters in reverse direction. For more information see - `TS.MREVRANGE `_. # noqa + `TS.MREVRANGE `_. Args: @@ -629,7 +629,7 @@ def mrevrange( labels of a series. align: Timestamp for alignment control for aggregation. - """ + """ # noqa params = self.__mrange_params( aggregation_type, bucket_size_msec, @@ -653,14 +653,14 @@ def get(self, key): """ # noqa Get the last sample of `key`. For more information see `TS.GET `_. - """ + """ # noqa return self.execute_command(GET_CMD, key) def mget(self, filters, with_labels=False): """ # noqa Get the last samples matching the specific `filter`. For more information see `TS.MGET `_. - """ + """ # noqa params = [] self._appendWithLabels(params, with_labels) params.extend(["FILTER"]) @@ -671,14 +671,14 @@ def info(self, key): """ # noqa Get information of `key`. For more information see `TS.INFO `_. - """ + """ # noqa return self.execute_command(INFO_CMD, key) def queryindex(self, filters): """ # noqa Get all the keys matching the `filter` list. For more information see `TS.QUERYINDEX `_. - """ + """ # noqa return self.execute_command(QUERYINDEX_CMD, *filters) @staticmethod diff --git a/tasks.py b/tasks.py index 137d1771bd..e4821949d8 100644 --- a/tasks.py +++ b/tasks.py @@ -20,6 +20,12 @@ def devenv(c): run(cmd) +@task +def build_docs(c): + """Generates the sphinx documentation.""" + run("tox -e docs") + + @task def linters(c): """Run code linters""" diff --git a/tox.ini b/tox.ini index 0a94db823b..33c397194d 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ markers = [tox] minversion = 3.2.0 requires = tox-docker -envlist = {standalone,cluster}-{plain,hiredis}-{py35,py36,py37,py38,py39,pypy3},linters +envlist = {standalone,cluster}-{plain,hiredis}-{py35,py36,py37,py38,py39,pypy3},linters,docs [docker:master] name = master @@ -128,7 +128,7 @@ commands = /usr/bin/echo [testenv:linters] deps_files = dev_requirements.txt -docker= +docker = commands = flake8 vulture redis whitelist.py --min-confidence 80 @@ -141,6 +141,13 @@ basepython = pypy3 [testenv:pypy3-hiredis] basepython = pypy3 +[testenv:docs] +deps_files = docs/requirements.txt +docker = +changedir = {toxinidir}/docs +allowlist_externals = make +commands = make html + [flake8] exclude = *.egg-info, From 884f7adc871fbae352dfea099a57a1534a8588c6 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 16:32:28 +0200 Subject: [PATCH 0272/1164] Fixing deprecating distutils (PEP 632) (#1730) --- redis/connection.py | 10 +++++----- requirements.txt | 1 + setup.py | 3 ++- tox.ini | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/redis/connection.py b/redis/connection.py index 2f91fae890..6ff3650805 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -1,4 +1,4 @@ -from distutils.version import LooseVersion +from packaging.version import Version from itertools import chain from time import time from queue import LifoQueue, Empty, Full @@ -55,13 +55,13 @@ if HIREDIS_AVAILABLE: import hiredis - hiredis_version = LooseVersion(hiredis.__version__) + hiredis_version = Version(hiredis.__version__) HIREDIS_SUPPORTS_CALLABLE_ERRORS = \ - hiredis_version >= LooseVersion('0.1.3') + hiredis_version >= Version('0.1.3') HIREDIS_SUPPORTS_BYTE_BUFFER = \ - hiredis_version >= LooseVersion('0.1.4') + hiredis_version >= Version('0.1.4') HIREDIS_SUPPORTS_ENCODING_ERRORS = \ - hiredis_version >= LooseVersion('1.0.0') + hiredis_version >= Version('1.0.0') HIREDIS_USE_BYTE_BUFFER = True # only use byte buffer if hiredis supports it diff --git a/requirements.txt b/requirements.txt index 9f8d5502da..f1e7e7ecdc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ deprecated +packaging diff --git a/setup.py b/setup.py index 6c712bd1d4..9acb501633 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,8 @@ author_email="oss@redis.com", python_requires=">=3.6", install_requires=[ - 'deprecated' + 'deprecated==1.2.3', + 'packaging==21.3', ], classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tox.ini b/tox.ini index 33c397194d..dd68274f2d 100644 --- a/tox.ini +++ b/tox.ini @@ -108,7 +108,7 @@ extras = setenv = CLUSTER_URL = "redis://localhost:16379/0" commands = - redis: pytest --cov=./ --cov-report=xml:coverage_redis.xml -W always -m 'not onlycluster' {posargs} + standalone: pytest --cov=./ --cov-report=xml:coverage_redis.xml -W always -m 'not onlycluster' {posargs} cluster: pytest --cov=./ --cov-report=xml:coverage_cluster.xml -W always -m 'not onlynoncluster and not redismod' --redis-url={env:CLUSTER_URL:} {posargs} [testenv:devenv] From 12f45ee2d8cc2c15b89ae579bf27b448ef0d1c2e Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 16:36:50 +0200 Subject: [PATCH 0273/1164] Removing duplication of Script class (#1751) --- redis/client.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/redis/client.py b/redis/client.py index 30a5c3c661..0ae64be9c9 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1,7 +1,6 @@ from itertools import chain import copy import datetime -import hashlib import re import threading import time @@ -15,7 +14,6 @@ ConnectionError, ExecAbortError, ModuleError, - NoScriptError, PubSubError, RedisError, ResponseError, @@ -1896,37 +1894,3 @@ def watch(self, *names): def unwatch(self): "Unwatches all previously specified keys" return self.watching and self.execute_command('UNWATCH') or True - - -class Script: - "An executable Lua script object returned by ``register_script``" - - def __init__(self, registered_client, script): - self.registered_client = registered_client - self.script = script - # Precalculate and store the SHA1 hex digest of the script. - - if isinstance(script, str): - # We need the encoding from the client in order to generate an - # accurate byte representation of the script - encoder = registered_client.connection_pool.get_encoder() - script = encoder.encode(script) - self.sha = hashlib.sha1(script).hexdigest() - - def __call__(self, keys=[], args=[], client=None): - "Execute the script, passing any required ``args``" - if client is None: - client = self.registered_client - args = tuple(keys) + tuple(args) - # make sure the Redis server knows about the script - if isinstance(client, Pipeline): - # Make sure the pipeline can register the script before executing. - client.scripts.add(self) - try: - return client.evalsha(self.sha, len(keys), *args) - except NoScriptError: - # Maybe the client is pointed to a different server than the client - # that created this instance? - # Overwrite the sha just in case there was a discrepancy. - self.sha = client.script_load(self.script) - return client.evalsha(self.sha, len(keys), *args) From 4db85ef574a64a2b230a3ae1ff19c9d04065a114 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 25 Nov 2021 16:46:14 +0200 Subject: [PATCH 0274/1164] Updating PR template (#1745) --- .github/PULL_REQUEST_TEMPLATE.md | 1 + examples/README.md | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 examples/README.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2bbc804d08..58062a1e2d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,7 @@ _Please make sure to review and check all of these items:_ - [ ] 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)? _NOTE: these things are not required to open a PR and can be done afterwards / while the PR is open._ diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..ca6d5dcfa3 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,3 @@ +# Examples + +Examples of redis-py usage go here. They're being linked to the [generated documentation](https://redis-py.readthedocs.org). From fbe87acf96aab583975ed3371423eb20602eccaf Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 30 Nov 2021 17:20:43 +0200 Subject: [PATCH 0275/1164] Pyupgrade + flynt + f-strings (#1759) @akx Thank you so much for this! Thanks again for introducing me to a new tool that I'm sliding into my workflow as well. --- benchmarks/base.py | 6 +- benchmarks/basic_operations.py | 23 ++-- benchmarks/command_packer_benchmark.py | 11 +- dev_requirements.txt | 1 + redis/client.py | 18 +-- redis/cluster.py | 134 +++++++++++------------ redis/commands/cluster.py | 20 ++-- redis/commands/core.py | 45 ++++---- redis/commands/helpers.py | 2 +- redis/commands/json/path.py | 2 +- redis/commands/parser.py | 5 +- redis/commands/search/__init__.py | 2 +- redis/commands/search/aggregation.py | 27 +++-- redis/commands/search/commands.py | 2 +- redis/commands/search/document.py | 4 +- redis/commands/search/field.py | 2 +- redis/commands/search/indexDefinition.py | 5 +- redis/commands/search/query.py | 10 +- redis/commands/search/querystring.py | 22 ++-- redis/commands/search/reducers.py | 24 ++-- redis/commands/search/result.py | 4 +- redis/commands/search/suggestion.py | 4 +- redis/commands/timeseries/info.py | 2 +- redis/connection.py | 65 ++++++----- redis/sentinel.py | 28 ++--- tasks.py | 4 +- tests/conftest.py | 20 ++-- tests/test_cluster.py | 23 ++-- tests/test_commands.py | 32 +++--- tests/test_connection.py | 2 +- tests/test_multiprocessing.py | 7 +- tests/test_pipeline.py | 2 +- tests/test_pubsub.py | 2 +- tests/test_search.py | 4 +- tests/test_timeseries.py | 2 +- tox.ini | 3 +- 36 files changed, 263 insertions(+), 306 deletions(-) diff --git a/benchmarks/base.py b/benchmarks/base.py index 8c13afe34d..519c9ccab5 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -34,12 +34,12 @@ def run_benchmark(self): group_values = [group['values'] for group in self.ARGUMENTS] for value_set in itertools.product(*group_values): pairs = list(zip(group_names, value_set)) - arg_string = ', '.join(['%s=%s' % (p[0], p[1]) for p in pairs]) - sys.stdout.write('Benchmark: %s... ' % arg_string) + arg_string = ', '.join(f'{p[0]}={p[1]}' for p in pairs) + sys.stdout.write(f'Benchmark: {arg_string}... ') sys.stdout.flush() kwargs = dict(pairs) setup = functools.partial(self.setup, **kwargs) run = functools.partial(self.run, **kwargs) t = timeit.timeit(stmt=run, setup=setup, number=1000) - sys.stdout.write('%f\n' % t) + sys.stdout.write(f'{t:f}\n') sys.stdout.flush() diff --git a/benchmarks/basic_operations.py b/benchmarks/basic_operations.py index 9446343251..cb009debbd 100644 --- a/benchmarks/basic_operations.py +++ b/benchmarks/basic_operations.py @@ -49,9 +49,9 @@ def wrapper(*args, **kwargs): count = kwargs['num'] else: count = args[1] - print('{} - {} Requests'.format(func.__name__, count)) - print('Duration = {}'.format(duration)) - print('Rate = {}'.format(count/duration)) + print(f'{func.__name__} - {count} Requests') + print(f'Duration = {duration}') + print(f'Rate = {count/duration}') print() return ret return wrapper @@ -62,10 +62,9 @@ def set_str(conn, num, pipeline_size, data_size): if pipeline_size > 1: conn = conn.pipeline() - format_str = '{:0<%d}' % data_size - set_data = format_str.format('a') + set_data = 'a'.ljust(data_size, '0') for i in range(num): - conn.set('set_str:%d' % i, set_data) + conn.set(f'set_str:{i}', set_data) if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -78,10 +77,9 @@ def set_int(conn, num, pipeline_size, data_size): if pipeline_size > 1: conn = conn.pipeline() - format_str = '{:0<%d}' % data_size - set_data = int(format_str.format('1')) + set_data = 10 ** (data_size - 1) for i in range(num): - conn.set('set_int:%d' % i, set_data) + conn.set(f'set_int:{i}', set_data) if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -95,7 +93,7 @@ def get_str(conn, num, pipeline_size, data_size): conn = conn.pipeline() for i in range(num): - conn.get('set_str:%d' % i) + conn.get(f'set_str:{i}') if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -109,7 +107,7 @@ def get_int(conn, num, pipeline_size, data_size): conn = conn.pipeline() for i in range(num): - conn.get('set_int:%d' % i) + conn.get(f'set_int:{i}') if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -136,8 +134,7 @@ def lpush(conn, num, pipeline_size, data_size): if pipeline_size > 1: conn = conn.pipeline() - format_str = '{:0<%d}' % data_size - set_data = int(format_str.format('1')) + set_data = 10 ** (data_size - 1) for i in range(num): conn.lpush('lpush_key', set_data) if pipeline_size > 1 and i % pipeline_size == 0: diff --git a/benchmarks/command_packer_benchmark.py b/benchmarks/command_packer_benchmark.py index 823a8c8469..3176c06800 100644 --- a/benchmarks/command_packer_benchmark.py +++ b/benchmarks/command_packer_benchmark.py @@ -1,4 +1,3 @@ -import socket from redis.connection import (Connection, SYM_STAR, SYM_DOLLAR, SYM_EMPTY, SYM_CRLF) from base import Benchmark @@ -11,14 +10,13 @@ def send_packed_command(self, command, check_health=True): self.connect() try: self._sock.sendall(command) - except socket.error as e: + except OSError as e: self.disconnect() if len(e.args) == 1: _errno, errmsg = 'UNKNOWN', e.args[0] else: _errno, errmsg = e.args - raise ConnectionError("Error %s while writing to socket. %s." % - (_errno, errmsg)) + raise ConnectionError(f"Error {_errno} while writing to socket. {errmsg}.") except Exception: self.disconnect() raise @@ -43,14 +41,13 @@ def send_packed_command(self, command, check_health=True): command = [command] for item in command: self._sock.sendall(item) - except socket.error as e: + except OSError as e: self.disconnect() if len(e.args) == 1: _errno, errmsg = 'UNKNOWN', e.args[0] else: _errno, errmsg = e.args - raise ConnectionError("Error %s while writing to socket. %s." % - (_errno, errmsg)) + raise ConnectionError(f"Error {_errno} while writing to socket. {errmsg}.") except Exception: self.disconnect() raise diff --git a/dev_requirements.txt b/dev_requirements.txt index 7f099cb541..56ac08efe2 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,4 +1,5 @@ flake8>=3.9.2 +flynt~=0.69.0 pytest==6.2.5 pytest-timeout==2.0.1 tox==3.24.4 diff --git a/redis/client.py b/redis/client.py index 0ae64be9c9..9f2907ee52 100755 --- a/redis/client.py +++ b/redis/client.py @@ -629,7 +629,7 @@ def parse_set_result(response, **options): return response and str_if_bytes(response) == 'OK' -class Redis(RedisModuleCommands, CoreCommands, SentinelCommands, object): +class Redis(RedisModuleCommands, CoreCommands, SentinelCommands): """ Implementation of the Redis protocol. @@ -918,7 +918,7 @@ def __init__(self, host='localhost', port=6379, self.__class__.RESPONSE_CALLBACKS) def __repr__(self): - return "%s<%s>" % (type(self).__name__, repr(self.connection_pool)) + return f"{type(self).__name__}<{repr(self.connection_pool)}>" def set_response_callback(self, command, callback): "Set a custom Response Callback" @@ -1141,7 +1141,7 @@ def __enter__(self): # check that monitor returns 'OK', but don't return it to user response = self.connection.read_response() if not bool_ok(response): - raise RedisError('MONITOR failed: %s' % response) + raise RedisError(f'MONITOR failed: {response}') return self def __exit__(self, *args): @@ -1517,12 +1517,10 @@ def run_in_thread(self, sleep_time=0, daemon=False, exception_handler=None): for channel, handler in self.channels.items(): if handler is None: - raise PubSubError("Channel: '%s' has no handler registered" % - channel) + raise PubSubError(f"Channel: '{channel}' has no handler registered") for pattern, handler in self.patterns.items(): if handler is None: - raise PubSubError("Pattern: '%s' has no handler registered" % - pattern) + raise PubSubError(f"Pattern: '{pattern}' has no handler registered") thread = PubSubWorkerThread( self, @@ -1807,8 +1805,10 @@ def raise_first_error(self, commands, response): def annotate_exception(self, exception, number, command): cmd = ' '.join(map(safe_str, command)) - msg = 'Command # %d (%s) of pipeline caused error: %s' % ( - number, cmd, exception.args[0]) + msg = ( + f'Command # {number} ({cmd}) of pipeline ' + f'caused error: {exception.args[0]}' + ) exception.args = (msg,) + exception.args[1:] def parse_response(self, connection, command_name, **options): diff --git a/redis/cluster.py b/redis/cluster.py index 91a4d558a2..c1853aa876 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -42,7 +42,7 @@ def get_node_name(host, port): - return '{0}:{1}'.format(host, port) + return f'{host}:{port}' def get_connection(redis_node, *args, **options): @@ -200,7 +200,7 @@ class ClusterParser(DefaultParser): }) -class RedisCluster(ClusterCommands, object): +class RedisCluster(ClusterCommands): RedisClusterRequestTTL = 16 PRIMARIES = "primaries" @@ -451,7 +451,7 @@ def __init__( "2. list of startup nodes, for example:\n" " RedisCluster(startup_nodes=[ClusterNode('localhost', 6379)," " ClusterNode('localhost', 6378)])") - log.debug("startup_nodes : {0}".format(startup_nodes)) + log.debug(f"startup_nodes : {startup_nodes}") # Update the connection arguments # Whenever a new connection is established, RedisCluster's on_connect # method should be run @@ -602,7 +602,7 @@ def get_node_from_key(self, key, replica=False): slot_cache = self.nodes_manager.slots_cache.get(slot) if slot_cache is None or len(slot_cache) == 0: raise SlotNotCoveredError( - 'Slot "{0}" is not covered by the cluster.'.format(slot) + f'Slot "{slot}" is not covered by the cluster.' ) if replica and len(self.nodes_manager.slots_cache[slot]) < 2: return None @@ -631,7 +631,7 @@ def set_default_node(self, node): "the default node was not changed.") return False self.nodes_manager.default_node = node - log.info("Changed the default cluster node to {0}".format(node)) + log.info(f"Changed the default cluster node to {node}") return True def pubsub(self, node=None, host=None, port=None, **kwargs): @@ -678,8 +678,7 @@ def _determine_nodes(self, *args, **kwargs): # get the nodes group for this command if it was predefined command_flag = self.command_flags.get(command) if command_flag: - log.debug("Target node/s for {0}: {1}". - format(command, command_flag)) + log.debug(f"Target node/s for {command}: {command_flag}") if command_flag == self.__class__.RANDOM: # return a random node return [self.get_random_node()] @@ -700,7 +699,7 @@ def _determine_nodes(self, *args, **kwargs): slot = self.determine_slot(*args) node = self.nodes_manager.get_node_from_slot( slot, self.read_from_replicas and command in READ_COMMANDS) - log.debug("Target for {0}: slot {1}".format(args, slot)) + log.debug(f"Target for {args}: slot {slot}") return [node] def _should_reinitialized(self): @@ -741,7 +740,7 @@ def determine_slot(self, *args): raise RedisClusterException( "No way to dispatch this command to Redis Cluster. " "Missing key.\nYou can execute the command by specifying " - "target nodes.\nCommand: {0}".format(args) + f"target nodes.\nCommand: {args}" ) if len(keys) > 1: @@ -749,8 +748,9 @@ def determine_slot(self, *args): # the same slot slots = {self.keyslot(key) for key in keys} if len(slots) != 1: - raise RedisClusterException("{0} - all keys must map to the " - "same key slot".format(args[0])) + raise RedisClusterException( + f"{args[0]} - all keys must map to the same key slot" + ) return slots.pop() else: # single key command @@ -775,12 +775,12 @@ def _parse_target_nodes(self, target_nodes): # rc.cluster_save_config(rc.get_primaries()) nodes = target_nodes.values() else: - raise TypeError("target_nodes type can be one of the " - "followings: node_flag (PRIMARIES, " - "REPLICAS, RANDOM, ALL_NODES)," - "ClusterNode, list, or " - "dict. The passed type is {0}". - format(type(target_nodes))) + raise TypeError( + "target_nodes type can be one of the following: " + "node_flag (PRIMARIES, REPLICAS, RANDOM, ALL_NODES)," + "ClusterNode, list, or dict. " + f"The passed type is {type(target_nodes)}" + ) return nodes def execute_command(self, *args, **kwargs): @@ -824,8 +824,7 @@ def execute_command(self, *args, **kwargs): *args, **kwargs, nodes_flag=target_nodes) if not target_nodes: raise RedisClusterException( - "No targets were found to execute" - " {} command on".format(args)) + f"No targets were found to execute {args} command on") for node in target_nodes: res[node.name] = self._execute_command( node, *args, **kwargs) @@ -868,9 +867,10 @@ def _execute_command(self, target_node, *args, **kwargs): command in READ_COMMANDS) moved = False - log.debug("Executing command {0} on target node: {1} {2}". - format(command, target_node.server_type, - target_node.name)) + log.debug( + f"Executing command {command} on target node: " + f"{target_node.server_type} {target_node.name}" + ) redis_node = self.get_redis_connection(target_node) connection = get_connection(redis_node, *args, **kwargs) if asking: @@ -952,7 +952,7 @@ def _execute_command(self, target_node, *args, **kwargs): raise e except ResponseError as e: message = e.__str__() - log.exception("ResponseError: {0}".format(message)) + log.exception(f"ResponseError: {message}") raise e except BaseException as e: log.exception("BaseException") @@ -995,7 +995,7 @@ def _process_result(self, command, res, **kwargs): return res -class ClusterNode(object): +class ClusterNode: def __init__(self, host, port, server_type=None, redis_connection=None): if host == 'localhost': host = socket.gethostbyname(host) @@ -1007,13 +1007,13 @@ def __init__(self, host, port, server_type=None, redis_connection=None): self.redis_connection = redis_connection def __repr__(self): - return '[host={0},port={1},' \ - 'name={2},server_type={3},redis_connection={4}]' \ - .format(self.host, - self.port, - self.name, - self.server_type, - self.redis_connection) + return ( + f'[host={self.host},' + f'port={self.port},' + f'name={self.name},' + f'server_type={self.server_type},' + f'redis_connection={self.redis_connection}]' + ) def __eq__(self, obj): return isinstance(obj, ClusterNode) and obj.name == self.name @@ -1135,9 +1135,8 @@ def get_node_from_slot(self, slot, read_from_replicas=False, if self.slots_cache.get(slot) is None or \ len(self.slots_cache[slot]) == 0: raise SlotNotCoveredError( - 'Slot "{0}" not covered by the cluster. ' - '"require_full_coverage={1}"'.format( - slot, self._require_full_coverage) + f'Slot "{slot}" not covered by the cluster. ' + f'"require_full_coverage={self._require_full_coverage}"' ) if read_from_replicas is True: @@ -1196,7 +1195,7 @@ def node_require_full_coverage(node): except Exception as e: raise RedisClusterException( 'ERROR sending "config get cluster-require-full-coverage"' - ' command to redis server: {0}, {1}'.format(node.name, e) + f' command to redis server: {node.name}, {e}' ) # at least one node should have cluster-require-full-coverage yes @@ -1269,7 +1268,7 @@ def initialize(self): msg = e.__str__ log.exception('An exception occurred while trying to' ' initialize the cluster using the seed node' - ' {0}:\n{1}'.format(startup_node.name, msg)) + f' {startup_node.name}:\n{msg}') continue except ResponseError as e: log.exception( @@ -1283,15 +1282,13 @@ def initialize(self): else: raise RedisClusterException( 'ERROR sending "cluster slots" command to redis ' - 'server: {0}. error: {1}'.format( - startup_node, message) + f'server: {startup_node}. error: {message}' ) except Exception as e: message = e.__str__() raise RedisClusterException( 'ERROR sending "cluster slots" command to redis ' - 'server: {0}. error: {1}'.format( - startup_node, message) + f'server: {startup_node}. error: {message}' ) # CLUSTER SLOTS command results in the following output: @@ -1342,17 +1339,16 @@ def initialize(self): else: # Validate that 2 nodes want to use the same slot cache # setup - if tmp_slots[i][0].name != target_node.name: + tmp_slot = tmp_slots[i][0] + if tmp_slot.name != target_node.name: disagreements.append( - '{0} vs {1} on slot: {2}'.format( - tmp_slots[i][0].name, target_node.name, i) + f'{tmp_slot.name} vs {target_node.name} on slot: {i}' ) if len(disagreements) > 5: raise RedisClusterException( - 'startup_nodes could not agree on a valid' - ' slots cache: {0}'.format( - ", ".join(disagreements)) + f'startup_nodes could not agree on a valid ' + f'slots cache: {", ".join(disagreements)}' ) if not startup_nodes_reachable: @@ -1370,9 +1366,8 @@ def initialize(self): # Despite the requirement that the slots be covered, there # isn't a full coverage raise RedisClusterException( - 'All slots are not covered after query all startup_nodes.' - ' {0} of {1} covered...'.format( - len(self.slots_cache), REDIS_CLUSTER_HASH_SLOTS) + f'All slots are not covered after query all startup_nodes. ' + f'{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} covered...' ) elif not fully_covered and not self._require_full_coverage: # The user set require_full_coverage to False. @@ -1389,8 +1384,7 @@ def initialize(self): 'cluster-require-full-coverage configuration to no on ' 'all of the cluster nodes if you wish the cluster to ' 'be able to serve without being fully covered.' - ' {0} of {1} covered...'.format( - len(self.slots_cache), REDIS_CLUSTER_HASH_SLOTS) + f'{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} covered...' ) # Set the tmp variables to the real variables @@ -1495,8 +1489,7 @@ def _raise_on_invalid_node(self, redis_cluster, node, host, port): """ if node is None or redis_cluster.get_node(node_name=node.name) is None: raise RedisClusterException( - "Node {0}:{1} doesn't exist in the cluster" - .format(host, port)) + f"Node {host}:{port} doesn't exist in the cluster") def execute_command(self, *args, **kwargs): """ @@ -1585,7 +1578,7 @@ def __init__(self, nodes_manager, result_callbacks=None, def __repr__(self): """ """ - return "{0}".format(type(self).__name__) + return f"{type(self).__name__}" def __enter__(self): """ @@ -1645,8 +1638,10 @@ def annotate_exception(self, exception, number, command): Provides extra context to the exception prior to it being handled """ cmd = ' '.join(map(safe_str, command)) - msg = 'Command # %d (%s) of pipeline caused error: %s' % ( - number, cmd, exception.args[0]) + msg = ( + f'Command # {number} ({cmd}) of pipeline ' + f'caused error: {exception.args[0]}' + ) exception.args = (msg,) + exception.args[1:] def execute(self, raise_on_error=True): @@ -1813,8 +1808,8 @@ def _send_cluster_commands(self, stack, # if we have more commands to attempt, we've run into problems. # collect all the commands we are allowed to retry. # (MOVED, ASK, or connection errors or timeout errors) - attempt = sorted([c for c in attempt - if isinstance(c.result, ERRORS_ALLOW_RETRY)], + attempt = sorted((c for c in attempt + if isinstance(c.result, ERRORS_ALLOW_RETRY)), key=lambda x: x.position) if attempt and allow_redirections: # RETRY MAGIC HAPPENS HERE! @@ -1835,12 +1830,12 @@ def _send_cluster_commands(self, stack, # If a lot of commands have failed, we'll be setting the # flag to rebuild the slots table from scratch. # So MOVED errors should correct themselves fairly quickly. - msg = 'An exception occurred during pipeline execution. ' \ - 'args: {0}, error: {1} {2}'.\ - format(attempt[-1].args, - type(attempt[-1].result).__name__, - str(attempt[-1].result)) - log.exception(msg) + log.exception( + f'An exception occurred during pipeline execution. ' + f'args: {attempt[-1].args}, ' + f'error: {type(attempt[-1].result).__name__} ' + f'{str(attempt[-1].result)}' + ) self.reinitialize_counter += 1 if self._should_reinitialized(): self.nodes_manager.initialize() @@ -1848,8 +1843,7 @@ def _send_cluster_commands(self, stack, try: # send each command individually like we # do in the main client. - c.result = super(ClusterPipeline, self). \ - execute_command(*c.args, **c.options) + c.result = super().execute_command(*c.args, **c.options) except RedisError as e: c.result = e @@ -1933,8 +1927,8 @@ def block_pipeline_command(func): def inner(*args, **kwargs): raise RedisClusterException( - "ERROR: Calling pipelined function {0} is blocked when " - "running redis in cluster mode...".format(func.__name__)) + f"ERROR: Calling pipelined function {func.__name__} is blocked when " + f"running redis in cluster mode...") return inner @@ -1977,7 +1971,7 @@ def inner(*args, **kwargs): ClusterPipeline.readonly = block_pipeline_command(RedisCluster.readonly) -class PipelineCommand(object): +class PipelineCommand: """ """ @@ -1992,7 +1986,7 @@ def __init__(self, args, options=None, position=None): self.asking = False -class NodeCommands(object): +class NodeCommands: """ """ diff --git a/redis/commands/cluster.py b/redis/commands/cluster.py index 6c7740d5e9..e6b0a08924 100644 --- a/redis/commands/cluster.py +++ b/redis/commands/cluster.py @@ -228,8 +228,7 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, if _type is not None: client_types = ('normal', 'master', 'slave', 'pubsub') if str(_type).lower() not in client_types: - raise DataError("CLIENT KILL type must be one of %r" % ( - client_types,)) + raise DataError(f"CLIENT KILL type must be one of {client_types!r}") args.extend((b'TYPE', _type)) if skipme is not None: if not isinstance(skipme, bool): @@ -267,8 +266,7 @@ def client_list(self, _type=None, target_nodes=None): if _type is not None: client_types = ('normal', 'master', 'replica', 'pubsub') if str(_type).lower() not in client_types: - raise DataError("CLIENT LIST _type must be one of %r" % ( - client_types,)) + raise DataError(f"CLIENT LIST _type must be one of {client_types!r}") return self.execute_command('CLIENT LIST', b'TYPE', _type, @@ -302,7 +300,7 @@ def client_reply(self, reply, target_nodes=None): """ replies = ['ON', 'OFF', 'SKIP'] if reply not in replies: - raise DataError('CLIENT REPLY must be one of %r' % replies) + raise DataError(f'CLIENT REPLY must be one of {replies!r}') return self.execute_command("CLIENT REPLY", reply, target_nodes=target_nodes) @@ -640,11 +638,10 @@ def stralgo(self, algo, value1, value2, specific_argument='strings', # check validity supported_algo = ['LCS'] if algo not in supported_algo: - raise DataError("The supported algorithms are: %s" - % (', '.join(supported_algo))) + supported_algos_str = ', '.join(supported_algo) + raise DataError(f"The supported algorithms are: {supported_algos_str}") if specific_argument not in ['keys', 'strings']: - raise DataError("specific_argument can be only" - " keys or strings") + raise DataError("specific_argument can be only keys or strings") if len and idx: raise DataError("len and idx cannot be provided together.") @@ -788,8 +785,7 @@ def cluster_failover(self, target_node, option=None): if option: if option.upper() not in ['FORCE', 'TAKEOVER']: raise RedisError( - 'Invalid option for CLUSTER FAILOVER command: {0}'.format( - option)) + f'Invalid option for CLUSTER FAILOVER command: {option}') else: return self.execute_command('CLUSTER FAILOVER', option, target_nodes=target_node) @@ -880,7 +876,7 @@ def cluster_setslot(self, target_node, node_id, slot_id, state): raise RedisError('For "stable" state please use ' 'cluster_setslot_stable') else: - raise RedisError('Invalid slot state: {0}'.format(state)) + raise RedisError(f'Invalid slot state: {state}') def cluster_setslot_stable(self, slot_id): """ diff --git a/redis/commands/core.py b/redis/commands/core.py index 64e3b6d37e..0285f80e0f 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -227,8 +227,8 @@ def acl_setuser(self, username, enabled=False, nopass=False, elif password.startswith(b'-'): pieces.append(b'<%s' % password[1:]) else: - raise DataError('Password %d must be prefixeed with a ' - '"+" to add or a "-" to remove' % i) + raise DataError(f'Password {i} must be prefixed with a ' + f'"+" to add or a "-" to remove') if hashed_passwords: # as most users will have only one password, allow remove_passwords @@ -241,8 +241,8 @@ def acl_setuser(self, username, enabled=False, nopass=False, elif hashed_password.startswith(b'-'): pieces.append(b'!%s' % hashed_password[1:]) else: - raise DataError('Hashed %d password must be prefixeed ' - 'with a "+" to add or a "-" to remove' % i) + raise DataError(f'Hashed password {i} must be prefixed with a ' + f'"+" to add or a "-" to remove') if nopass: pieces.append(b'nopass') @@ -260,16 +260,18 @@ def acl_setuser(self, username, enabled=False, nopass=False, elif category.startswith(b'-'): pieces.append(b'-@%s' % category[1:]) else: - raise DataError('Category "%s" must be prefixed with ' - '"+" or "-"' - % encoder.decode(category, force=True)) + raise DataError( + f'Category "{encoder.decode(category, force=True)}" ' + 'must be prefixed with "+" or "-"' + ) if commands: for cmd in commands: cmd = encoder.encode(cmd) if not cmd.startswith(b'+') and not cmd.startswith(b'-'): - raise DataError('Command "%s" must be prefixed with ' - '"+" or "-"' - % encoder.decode(cmd, force=True)) + raise DataError( + f'Command "{encoder.decode(cmd, force=True)}" ' + 'must be prefixed with "+" or "-"' + ) pieces.append(cmd) if keys: @@ -342,8 +344,7 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, if _type is not None: client_types = ('normal', 'master', 'slave', 'pubsub') if str(_type).lower() not in client_types: - raise DataError("CLIENT KILL type must be one of %r" % ( - client_types,)) + raise DataError(f"CLIENT KILL type must be one of {client_types!r}") args.extend((b'TYPE', _type)) if skipme is not None: if not isinstance(skipme, bool): @@ -388,8 +389,7 @@ def client_list(self, _type=None, client_id=[]): if _type is not None: client_types = ('normal', 'master', 'replica', 'pubsub') if str(_type).lower() not in client_types: - raise DataError("CLIENT LIST _type must be one of %r" % ( - client_types,)) + raise DataError(f"CLIENT LIST _type must be one of {client_types!r}") args.append(b'TYPE') args.append(_type) if not isinstance(client_id, list): @@ -434,7 +434,7 @@ def client_reply(self, reply): """ replies = ['ON', 'OFF', 'SKIP'] if reply not in replies: - raise DataError('CLIENT REPLY must be one of %r' % replies) + raise DataError(f'CLIENT REPLY must be one of {replies!r}') return self.execute_command("CLIENT REPLY", reply) def client_id(self): @@ -551,7 +551,7 @@ def config_rewrite(self): return self.execute_command('CONFIG REWRITE') def cluster(self, cluster_arg, *args): - return self.execute_command('CLUSTER %s' % cluster_arg.upper(), *args) + return self.execute_command(f'CLUSTER {cluster_arg.upper()}', *args) def dbsize(self): """ @@ -1086,7 +1086,7 @@ def getex(self, name, For more information check https://redis.io/commands/getex """ - opset = set([ex, px, exat, pxat]) + opset = {ex, px, exat, pxat} if len(opset) > 2 or len(opset) > 1 and persist: raise DataError("``ex``, ``px``, ``exat``, ``pxat``, " "and ``persist`` are mutually exclusive.") @@ -1554,11 +1554,10 @@ def stralgo(self, algo, value1, value2, specific_argument='strings', # check validity supported_algo = ['LCS'] if algo not in supported_algo: - raise DataError("The supported algorithms are: %s" - % (', '.join(supported_algo))) + supported_algos_str = ', '.join(supported_algo) + raise DataError(f"The supported algorithms are: {supported_algos_str}") if specific_argument not in ['keys', 'strings']: - raise DataError("specific_argument can be only" - " keys or strings") + raise DataError("specific_argument can be only keys or strings") if len and idx: raise DataError("len and idx cannot be provided together.") @@ -3466,8 +3465,8 @@ def hmset(self, name, mapping): For more information check https://redis.io/commands/hmset """ warnings.warn( - '%s.hmset() is deprecated. Use %s.hset() instead.' - % (self.__class__.__name__, self.__class__.__name__), + f'{self.__class__.__name__}.hmset() is deprecated. ' + f'Use {self.__class__.__name__}.hset() instead.', DeprecationWarning, stacklevel=2, ) diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index 5e8ff49d9b..dc5705b80b 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -113,4 +113,4 @@ def quote_string(v): v = v.replace('"', '\\"') - return '"{}"'.format(v) + return f'"{v}"' diff --git a/redis/commands/json/path.py b/redis/commands/json/path.py index 6d87045155..f0a413a00d 100644 --- a/redis/commands/json/path.py +++ b/redis/commands/json/path.py @@ -1,4 +1,4 @@ -class Path(object): +class Path: """This class represents a path in a JSON value.""" strPath = "" diff --git a/redis/commands/parser.py b/redis/commands/parser.py index d8b03271db..26b190c674 100644 --- a/redis/commands/parser.py +++ b/redis/commands/parser.py @@ -46,8 +46,9 @@ def get_keys(self, redis_conn, *args): # version has changed, the commands may not be current self.initialize(redis_conn) if cmd_name not in self.commands: - raise RedisError("{0} command doesn't exist in Redis " - "commands".format(cmd_name.upper())) + raise RedisError( + f"{cmd_name.upper()} command doesn't exist in Redis commands" + ) command = self.commands.get(cmd_name) if 'movablekeys' in command['flags']: diff --git a/redis/commands/search/__init__.py b/redis/commands/search/__init__.py index 8320ad4392..a30cebe1b7 100644 --- a/redis/commands/search/__init__.py +++ b/redis/commands/search/__init__.py @@ -7,7 +7,7 @@ class Search(SearchCommands): It abstracts the API of the module and lets you just use the engine. """ - class BatchIndexer(object): + class BatchIndexer: """ A batch indexer allows you to automatically batch document indexing in pipelines, flushing it every N documents. diff --git a/redis/commands/search/aggregation.py b/redis/commands/search/aggregation.py index 3d71329c44..b1ac6b04dc 100644 --- a/redis/commands/search/aggregation.py +++ b/redis/commands/search/aggregation.py @@ -1,7 +1,7 @@ FIELDNAME = object() -class Limit(object): +class Limit: def __init__(self, offset=0, count=0): self.offset = offset self.count = count @@ -13,7 +13,7 @@ def build_args(self): return [] -class Reducer(object): +class Reducer: """ Base reducer object for all reducers. @@ -55,7 +55,7 @@ def args(self): return self._args -class SortDirection(object): +class SortDirection: """ This special class is used to indicate sort direction. """ @@ -82,7 +82,7 @@ class Desc(SortDirection): DIRSTRING = "DESC" -class Group(object): +class Group: """ This object automatically created in the `AggregateRequest.group_by()` """ @@ -109,7 +109,7 @@ def build_args(self): return ret -class Projection(object): +class Projection: """ This object automatically created in the `AggregateRequest.apply()` """ @@ -126,7 +126,7 @@ def build_args(self): return ret -class SortBy(object): +class SortBy: """ This object automatically created in the `AggregateRequest.sort_by()` """ @@ -151,7 +151,7 @@ def build_args(self): return ret -class AggregateRequest(object): +class AggregateRequest: """ Aggregation request which can be passed to `Client.aggregate`. """ @@ -370,7 +370,7 @@ def build_args(self): return ret -class Cursor(object): +class Cursor: def __init__(self, cid): self.cid = cid self.max_idle = 0 @@ -385,16 +385,15 @@ def build_args(self): return args -class AggregateResult(object): +class AggregateResult: def __init__(self, rows, cursor, schema): self.rows = rows self.cursor = cursor self.schema = schema def __repr__(self): - return "<{} at 0x{:x} Rows={}, Cursor={}>".format( - self.__class__.__name__, - id(self), - len(self.rows), - self.cursor.cid if self.cursor else -1, + cid = self.cursor.cid if self.cursor else -1 + return ( + f"<{self.__class__.__name__} at 0x{id(self):x} " + f"Rows={len(self.rows)}, Cursor={cid}>" ) diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py index ed58255fec..c19cb93e53 100644 --- a/redis/commands/search/commands.py +++ b/redis/commands/search/commands.py @@ -348,7 +348,7 @@ def _mk_query_args(self, query): # convert the query from a text to a query object query = Query(query) if not isinstance(query, Query): - raise ValueError("Bad query type %s" % type(query)) + raise ValueError(f"Bad query type {type(query)}") args += query.get_args() return args, query diff --git a/redis/commands/search/document.py b/redis/commands/search/document.py index 0d4255db17..5b3050545a 100644 --- a/redis/commands/search/document.py +++ b/redis/commands/search/document.py @@ -1,4 +1,4 @@ -class Document(object): +class Document: """ Represents a single document in a result set """ @@ -10,4 +10,4 @@ def __init__(self, id, payload=None, **fields): setattr(self, k, v) def __repr__(self): - return "Document %s" % self.__dict__ + return f"Document {self.__dict__}" diff --git a/redis/commands/search/field.py b/redis/commands/search/field.py index 45114a42b7..076c872b62 100644 --- a/redis/commands/search/field.py +++ b/redis/commands/search/field.py @@ -1,4 +1,4 @@ -class Field(object): +class Field: NUMERIC = "NUMERIC" TEXT = "TEXT" diff --git a/redis/commands/search/indexDefinition.py b/redis/commands/search/indexDefinition.py index 4fbc6095c5..0c7a3b0635 100644 --- a/redis/commands/search/indexDefinition.py +++ b/redis/commands/search/indexDefinition.py @@ -8,7 +8,7 @@ class IndexType(Enum): JSON = 2 -class IndexDefinition(object): +class IndexDefinition: """IndexDefinition is used to define a index definition for automatic indexing on Hash or Json update.""" @@ -38,8 +38,7 @@ def _appendIndexType(self, index_type): elif index_type is IndexType.JSON: self.args.extend(["ON", "JSON"]) elif index_type is not None: - raise RuntimeError("index_type must be one of {}". - format(list(IndexType))) + raise RuntimeError(f"index_type must be one of {list(IndexType)}") def _appendPrefix(self, prefix): """Append PREFIX.""" diff --git a/redis/commands/search/query.py b/redis/commands/search/query.py index 85a8255334..5534f7b88e 100644 --- a/redis/commands/search/query.py +++ b/redis/commands/search/query.py @@ -1,4 +1,4 @@ -class Query(object): +class Query: """ Query is used to build complex queries that have more parameters than just the query string. The query string is set in the constructor, and other @@ -291,7 +291,7 @@ def expander(self, expander): return self -class Filter(object): +class Filter: def __init__(self, keyword, field, *args): self.args = [keyword, field] + list(args) @@ -303,8 +303,8 @@ class NumericFilter(Filter): def __init__(self, field, minval, maxval, minExclusive=False, maxExclusive=False): args = [ - minval if not minExclusive else "({}".format(minval), - maxval if not maxExclusive else "({}".format(maxval), + minval if not minExclusive else f"({minval}", + maxval if not maxExclusive else f"({maxval}", ] Filter.__init__(self, "FILTER", field, *args) @@ -320,6 +320,6 @@ def __init__(self, field, lon, lat, radius, unit=KILOMETERS): Filter.__init__(self, "GEOFILTER", field, lon, lat, radius, unit) -class SortbyField(object): +class SortbyField: def __init__(self, field, asc=True): self.args = [field, "ASC" if asc else "DESC"] diff --git a/redis/commands/search/querystring.py b/redis/commands/search/querystring.py index aecd3b82f5..ffba542e31 100644 --- a/redis/commands/search/querystring.py +++ b/redis/commands/search/querystring.py @@ -61,7 +61,7 @@ def geo(lat, lon, radius, unit="km"): return GeoValue(lat, lon, radius, unit) -class Value(object): +class Value: @property def combinable(self): """ @@ -134,7 +134,7 @@ def __init__(self, lon, lat, radius, unit="km"): self.unit = unit -class Node(object): +class Node: def __init__(self, *children, **kwparams): """ Create a node @@ -197,13 +197,11 @@ def __init__(self, *children, **kwparams): def join_fields(self, key, vals): if len(vals) == 1: - return [BaseNode("@{}:{}".format(key, vals[0].to_string()))] + return [BaseNode(f"@{key}:{vals[0].to_string()}")] if not vals[0].combinable: - return [BaseNode("@{}:{}".format(key, - v.to_string())) for v in vals] + return [BaseNode(f"@{key}:{v.to_string()}") for v in vals] s = BaseNode( - "@{}:({})".format(key, - self.JOINSTR.join(v.to_string() for v in vals)) + f"@{key}:({self.JOINSTR.join(v.to_string() for v in vals)})" ) return [s] @@ -220,9 +218,7 @@ def JOINSTR(self): def to_string(self, with_parens=None): with_parens = self._should_use_paren(with_parens) pre, post = ("(", ")") if with_parens else ("", "") - return "{}{}{}".format( - pre, self.JOINSTR.join(n.to_string() for n in self.params), post - ) + return f"{pre}{self.JOINSTR.join(n.to_string() for n in self.params)}{post}" def _should_use_paren(self, optval): if optval is not None: @@ -235,7 +231,7 @@ def __str__(self): class BaseNode(Node): def __init__(self, s): - super(BaseNode, self).__init__() + super().__init__() self.s = str(s) def to_string(self, with_parens=None): @@ -268,7 +264,7 @@ class DisjunctNode(IntersectNode): def to_string(self, with_parens=None): with_parens = self._should_use_paren(with_parens) - ret = super(DisjunctNode, self).to_string(with_parens=False) + ret = super().to_string(with_parens=False) if with_parens: return "(-" + ret + ")" else: @@ -294,7 +290,7 @@ class OptionalNode(IntersectNode): def to_string(self, with_parens=None): with_parens = self._should_use_paren(with_parens) - ret = super(OptionalNode, self).to_string(with_parens=False) + ret = super().to_string(with_parens=False) if with_parens: return "(~" + ret + ")" else: diff --git a/redis/commands/search/reducers.py b/redis/commands/search/reducers.py index 6cbbf2f355..41ed11a238 100644 --- a/redis/commands/search/reducers.py +++ b/redis/commands/search/reducers.py @@ -3,7 +3,7 @@ class FieldOnlyReducer(Reducer): def __init__(self, field): - super(FieldOnlyReducer, self).__init__(field) + super().__init__(field) self._field = field @@ -15,7 +15,7 @@ class count(Reducer): NAME = "COUNT" def __init__(self): - super(count, self).__init__() + super().__init__() class sum(FieldOnlyReducer): @@ -26,7 +26,7 @@ class sum(FieldOnlyReducer): NAME = "SUM" def __init__(self, field): - super(sum, self).__init__(field) + super().__init__(field) class min(FieldOnlyReducer): @@ -37,7 +37,7 @@ class min(FieldOnlyReducer): NAME = "MIN" def __init__(self, field): - super(min, self).__init__(field) + super().__init__(field) class max(FieldOnlyReducer): @@ -48,7 +48,7 @@ class max(FieldOnlyReducer): NAME = "MAX" def __init__(self, field): - super(max, self).__init__(field) + super().__init__(field) class avg(FieldOnlyReducer): @@ -59,7 +59,7 @@ class avg(FieldOnlyReducer): NAME = "AVG" def __init__(self, field): - super(avg, self).__init__(field) + super().__init__(field) class tolist(FieldOnlyReducer): @@ -70,7 +70,7 @@ class tolist(FieldOnlyReducer): NAME = "TOLIST" def __init__(self, field): - super(tolist, self).__init__(field) + super().__init__(field) class count_distinct(FieldOnlyReducer): @@ -82,7 +82,7 @@ class count_distinct(FieldOnlyReducer): NAME = "COUNT_DISTINCT" def __init__(self, field): - super(count_distinct, self).__init__(field) + super().__init__(field) class count_distinctish(FieldOnlyReducer): @@ -104,7 +104,7 @@ class quantile(Reducer): NAME = "QUANTILE" def __init__(self, field, pct): - super(quantile, self).__init__(field, str(pct)) + super().__init__(field, str(pct)) self._field = field @@ -116,7 +116,7 @@ class stddev(FieldOnlyReducer): NAME = "STDDEV" def __init__(self, field): - super(stddev, self).__init__(field) + super().__init__(field) class first_value(Reducer): @@ -155,7 +155,7 @@ def __init__(self, field, *byfields): args = [field] if fieldstrs: args += ["BY"] + fieldstrs - super(first_value, self).__init__(*args) + super().__init__(*args) self._field = field @@ -174,5 +174,5 @@ def __init__(self, field, size): **size**: Return this many items (can be less) """ args = [field, str(size)] - super(random_sample, self).__init__(*args) + super().__init__(*args) self._field = field diff --git a/redis/commands/search/result.py b/redis/commands/search/result.py index 9cd922ac1d..57ba53d5ca 100644 --- a/redis/commands/search/result.py +++ b/redis/commands/search/result.py @@ -2,7 +2,7 @@ from ._util import to_string -class Result(object): +class Result: """ Represents the result of a search query, and has an array of Document objects @@ -70,4 +70,4 @@ def __init__( self.docs.append(doc) def __repr__(self): - return "Result{%d total, docs: %s}" % (self.total, self.docs) + return f"Result{{{self.total} total, docs: {self.docs}}}" diff --git a/redis/commands/search/suggestion.py b/redis/commands/search/suggestion.py index 3401af94eb..6d295a652f 100644 --- a/redis/commands/search/suggestion.py +++ b/redis/commands/search/suggestion.py @@ -1,7 +1,7 @@ from ._util import to_string -class Suggestion(object): +class Suggestion: """ Represents a single suggestion being sent or returned from the autocomplete server @@ -16,7 +16,7 @@ def __repr__(self): return self.string -class SuggestionParser(object): +class SuggestionParser: """ Internal class used to parse results from the `SUGGET` command. This needs to consume either 1, 2, or 3 values at a time from diff --git a/redis/commands/timeseries/info.py b/redis/commands/timeseries/info.py index 3b89503f18..2b8acd1b66 100644 --- a/redis/commands/timeseries/info.py +++ b/redis/commands/timeseries/info.py @@ -2,7 +2,7 @@ from ..helpers import nativestr -class TSInfo(object): +class TSInfo: """ Hold information and statistics on the time-series. Can be created using ``tsinfo`` command diff --git a/redis/connection.py b/redis/connection.py index 6ff3650805..ef3a667c16 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -107,8 +107,8 @@ def encode(self, value): elif not isinstance(value, str): # a value we don't know how to deal with. throw an error typename = type(value).__name__ - raise DataError("Invalid input of type: '%s'. Convert to a " - "bytes, string, int or float first." % typename) + raise DataError(f"Invalid input of type: '{typename}'. " + f"Convert to a bytes, string, int or float first.") if isinstance(value, str): value = value.encode(self.encoding, self.encoding_errors) return value @@ -214,8 +214,7 @@ def _read_from_socket(self, length=None, timeout=SENTINEL, allowed = NONBLOCKING_EXCEPTION_ERROR_NUMBERS.get(ex.__class__, -1) if not raise_on_timeout and ex.errno == allowed: return False - raise ConnectionError("Error while reading from socket: %s" % - (ex.args,)) + raise ConnectionError(f"Error while reading from socket: {ex.args}") finally: if custom_timeout: sock.settimeout(self.socket_timeout) @@ -323,7 +322,7 @@ def read_response(self, disable_decoding=False): byte, response = raw[:1], raw[1:] if byte not in (b'-', b'+', b':', b'$', b'*'): - raise InvalidResponse("Protocol Error: %r" % raw) + raise InvalidResponse(f"Protocol Error: {raw!r}") # server returned an error if byte == b'-': @@ -445,8 +444,7 @@ def read_from_socket(self, timeout=SENTINEL, raise_on_timeout=True): allowed = NONBLOCKING_EXCEPTION_ERROR_NUMBERS.get(ex.__class__, -1) if not raise_on_timeout and ex.errno == allowed: return False - raise ConnectionError("Error while reading from socket: %s" % - (ex.args,)) + raise ConnectionError(f"Error while reading from socket: {ex.args}") finally: if custom_timeout: sock.settimeout(self._socket_timeout) @@ -538,8 +536,8 @@ def __init__(self, host='localhost', port=6379, db=0, password=None, self._buffer_cutoff = 6000 def __repr__(self): - repr_args = ','.join(['%s=%s' % (k, v) for k, v in self.repr_pieces()]) - return '%s<%s>' % (self.__class__.__name__, repr_args) + repr_args = ','.join([f'{k}={v}' for k, v in self.repr_pieces()]) + return f'{self.__class__.__name__}<{repr_args}>' def repr_pieces(self): pieces = [ @@ -579,7 +577,7 @@ def connect(self): sock = self._connect() except socket.timeout: raise TimeoutError("Timeout connecting to server") - except socket.error as e: + except OSError as e: raise ConnectionError(self._error_message(e)) self._sock = sock @@ -646,11 +644,12 @@ def _error_message(self, exception): # args for socket.error can either be (errno, "message") # or just "message" if len(exception.args) == 1: - return "Error connecting to %s:%s. %s." % \ - (self.host, self.port, exception.args[0]) + return f"Error connecting to {self.host}:{self.port}. {exception.args[0]}." else: - return "Error %s connecting to %s:%s. %s." % \ - (exception.args[0], self.host, self.port, exception.args[1]) + return ( + f"Error {exception.args[0]} connecting to " + f"{self.host}:{self.port}. {exception.args[1]}." + ) def on_connect(self): "Initialize the connection, authenticate and select a database" @@ -734,15 +733,14 @@ def send_packed_command(self, command, check_health=True): except socket.timeout: self.disconnect() raise TimeoutError("Timeout writing to socket") - except socket.error as e: + except OSError as e: self.disconnect() if len(e.args) == 1: errno, errmsg = 'UNKNOWN', e.args[0] else: errno = e.args[0] errmsg = e.args[1] - raise ConnectionError("Error %s while writing to socket. %s." % - (errno, errmsg)) + raise ConnectionError(f"Error {errno} while writing to socket. {errmsg}.") except BaseException: self.disconnect() raise @@ -767,12 +765,12 @@ def read_response(self, disable_decoding=False): ) except socket.timeout: self.disconnect() - raise TimeoutError("Timeout reading from %s:%s" % - (self.host, self.port)) - except socket.error as e: + raise TimeoutError(f"Timeout reading from {self.host}:{self.port}") + except OSError as e: self.disconnect() - raise ConnectionError("Error while reading from %s:%s : %s" % - (self.host, self.port, e.args)) + raise ConnectionError( + f"Error while reading from {self.host}:{self.port}" + f" : {e.args}") except BaseException: self.disconnect() raise @@ -867,8 +865,7 @@ def __init__(self, ssl_keyfile=None, ssl_certfile=None, } if ssl_cert_reqs not in CERT_REQS: raise RedisError( - "Invalid SSL Certificate Requirements Flag: %s" % - ssl_cert_reqs) + f"Invalid SSL Certificate Requirements Flag: {ssl_cert_reqs}") ssl_cert_reqs = CERT_REQS[ssl_cert_reqs] self.cert_reqs = ssl_cert_reqs self.ca_certs = ssl_ca_certs @@ -947,11 +944,12 @@ def _error_message(self, exception): # args for socket.error can either be (errno, "message") # or just "message" if len(exception.args) == 1: - return "Error connecting to unix socket: %s. %s." % \ - (self.path, exception.args[0]) + return f"Error connecting to unix socket: {self.path}. {exception.args[0]}." else: - return "Error %s connecting to unix socket: %s. %s." % \ - (exception.args[0], self.path, exception.args[1]) + return ( + f"Error {exception.args[0]} connecting to unix socket: " + f"{self.path}. {exception.args[1]}." + ) FALSE_STRINGS = ('0', 'F', 'FALSE', 'N', 'NO') @@ -990,7 +988,7 @@ def parse_url(url): kwargs[name] = parser(value) except (TypeError, ValueError): raise ValueError( - "Invalid value for `%s` in connection URL." % name + f"Invalid value for `{name}` in connection URL." ) else: kwargs[name] = value @@ -1023,9 +1021,8 @@ def parse_url(url): if url.scheme == 'rediss': kwargs['connection_class'] = SSLConnection else: - valid_schemes = 'redis://, rediss://, unix://' raise ValueError('Redis URL must specify one of the following ' - 'schemes (%s)' % valid_schemes) + 'schemes (redis://, rediss://, unix://)') return kwargs @@ -1109,9 +1106,9 @@ def __init__(self, connection_class=Connection, max_connections=None, self.reset() def __repr__(self): - return "%s<%s>" % ( - type(self).__name__, - repr(self.connection_class(**self.connection_kwargs)), + return ( + f"{type(self).__name__}" + f"<{repr(self.connection_class(**self.connection_kwargs))}>" ) def reset(self): diff --git a/redis/sentinel.py b/redis/sentinel.py index 3efd58fa39..06877bd167 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -24,9 +24,9 @@ def __init__(self, **kwargs): def __repr__(self): pool = self.connection_pool - s = '%s' % (type(self).__name__, pool.service_name) + s = f'{type(self).__name__}' if self.host: - host_info = ',host=%s,port=%s' % (self.host, self.port) + host_info = f',host={self.host},port={self.port}' s = s % host_info return s @@ -91,11 +91,8 @@ def __init__(self, service_name, sentinel_manager, **kwargs): self.sentinel_manager = sentinel_manager def __repr__(self): - return "%s' % ( - type(self).__name__, - ','.join(sentinel_addresses)) + return f'{type(self).__name__}' def check_master_state(self, state, service_name): if not state['is_master'] or state['is_sdown'] or state['is_odown']: @@ -240,7 +234,7 @@ def discover_master(self, service_name): self.sentinels[0], self.sentinels[sentinel_no] = ( sentinel, self.sentinels[0]) return state['ip'], state['port'] - raise MasterNotFoundError("No master found for %r" % (service_name,)) + raise MasterNotFoundError(f"No master found for {service_name!r}") def filter_slaves(self, slaves): "Remove slaves that are in an ODOWN or SDOWN state" diff --git a/tasks.py b/tasks.py index e4821949d8..8d9c4c64be 100644 --- a/tasks.py +++ b/tasks.py @@ -16,7 +16,7 @@ def devenv(c): clean(c) cmd = 'tox -e devenv' for d in dockers: - cmd += " --docker-dont-stop={}".format(d) + cmd += f" --docker-dont-stop={d}" run(cmd) @@ -73,7 +73,7 @@ def clean(c): shutil.rmtree("build") if os.path.isdir("dist"): shutil.rmtree("dist") - run("docker rm -f {}".format(' '.join(dockers))) + run(f"docker rm -f {' '.join(dockers)}") @task diff --git a/tests/conftest.py b/tests/conftest.py index ddc0834037..8ed39abddc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,8 +87,7 @@ def wait_for_cluster_creation(redis_url, cluster_nodes, timeout=20): now = time.time() end_time = now + timeout client = None - print("Waiting for {0} cluster nodes to become available". - format(cluster_nodes)) + print(f"Waiting for {cluster_nodes} cluster nodes to become available") while now < end_time: try: client = redis.RedisCluster.from_url(redis_url) @@ -102,9 +101,8 @@ def wait_for_cluster_creation(redis_url, cluster_nodes, timeout=20): if now >= end_time: available_nodes = 0 if client is None else len(client.get_nodes()) raise RedisClusterException( - "The cluster did not become available after {0} seconds. " - "Only {1} nodes out of {2} are available".format( - timeout, available_nodes, cluster_nodes)) + f"The cluster did not become available after {timeout} seconds. " + f"Only {available_nodes} nodes out of {cluster_nodes} are available") def skip_if_server_version_lt(min_version): @@ -112,7 +110,7 @@ def skip_if_server_version_lt(min_version): check = LooseVersion(redis_version) < LooseVersion(min_version) return pytest.mark.skipif( check, - reason="Redis version required >= {}".format(min_version)) + reason=f"Redis version required >= {min_version}") def skip_if_server_version_gte(min_version): @@ -120,12 +118,12 @@ def skip_if_server_version_gte(min_version): check = LooseVersion(redis_version) >= LooseVersion(min_version) return pytest.mark.skipif( check, - reason="Redis version required < {}".format(min_version)) + reason=f"Redis version required < {min_version}") def skip_unless_arch_bits(arch_bits): return pytest.mark.skipif(REDIS_INFO["arch_bits"] != arch_bits, - reason="server is not {}-bit".format(arch_bits)) + reason=f"server is not {arch_bits}-bit") def skip_ifmodversion_lt(min_version: str, module_name: str): @@ -144,7 +142,7 @@ def skip_ifmodversion_lt(min_version: str, module_name: str): check = version < mv return pytest.mark.skipif(check, reason="Redis module version") - raise AttributeError("No redis module named {}".format(module_name)) + raise AttributeError(f"No redis module named {module_name}") def skip_if_redis_enterprise(func): @@ -320,8 +318,8 @@ def wait_for_command(client, monitor, command): if LooseVersion(redis_version) >= LooseVersion('5.0.0'): id_str = str(client.client_id()) else: - id_str = '%08x' % random.randrange(2**32) - key = '__REDIS-PY-%s__' % id_str + id_str = f'{random.randrange(2 ** 32):08x}' + key = f'__REDIS-PY-{id_str}__' client.get(key) while True: monitor_response = monitor.next_command() diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 071cb7d2f1..d12e47ed02 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -178,7 +178,7 @@ def ok_response(connection, *args, **options): return "MOCK_OK" parse_response.side_effect = ok_response - raise MovedError("{0} {1}:{2}".format(slot, r_host, r_port)) + raise MovedError(f"{slot} {r_host}:{r_port}") parse_response.side_effect = moved_redirect_effect assert rc.execute_command("SET", "foo", "bar") == "MOCK_OK" @@ -229,7 +229,7 @@ def test_empty_startup_nodes(self): "cluster"), str_if_bytes(ex.value) def test_from_url(self, r): - redis_url = "redis://{0}:{1}/0".format(default_host, default_port) + redis_url = f"redis://{default_host}:{default_port}/0" with patch.object(RedisCluster, 'from_url') as from_url: def from_url_mocked(_url, **_kwargs): return get_mocked_redis_client(url=_url, **_kwargs) @@ -333,8 +333,7 @@ def ok_response(connection, *args, **options): return "MOCK_OK" parse_response.side_effect = ok_response - raise AskError("12182 {0}:{1}".format(redirect_node.host, - redirect_node.port)) + raise AskError(f"12182 {redirect_node.host}:{redirect_node.port}") parse_response.side_effect = ask_redirect_effect @@ -498,14 +497,14 @@ def test_keyslot(self, r): assert r.keyslot(125) == r.keyslot(b"125") assert r.keyslot(125) == r.keyslot("\x31\x32\x35") assert r.keyslot("大奖") == r.keyslot(b"\xe5\xa4\xa7\xe5\xa5\x96") - assert r.keyslot(u"大奖") == r.keyslot(b"\xe5\xa4\xa7\xe5\xa5\x96") + assert r.keyslot("大奖") == r.keyslot(b"\xe5\xa4\xa7\xe5\xa5\x96") assert r.keyslot(1337.1234) == r.keyslot("1337.1234") assert r.keyslot(1337) == r.keyslot("1337") assert r.keyslot(b"abc") == r.keyslot("abc") def test_get_node_name(self): assert get_node_name(default_host, default_port) == \ - "{0}:{1}".format(default_host, default_port) + f"{default_host}:{default_port}" def test_all_nodes(self, r): """ @@ -713,7 +712,7 @@ def test_pubsub_channels_merge_results(self, r): pubsub_nodes = [] i = 0 for node in nodes: - channel = "foo{0}".format(i) + channel = f"foo{i}" # We will create different pubsub clients where each one is # connected to a different node p = r.pubsub(node) @@ -1180,8 +1179,7 @@ def test_client_kill(self, r, r2): clients = [client for client in r.client_list(target_nodes=node) if client.get('name') in ['redis-py-c1', 'redis-py-c2']] assert len(clients) == 2 - clients_by_name = dict([(client.get('name'), client) - for client in clients]) + clients_by_name = {client.get('name'): client for client in clients} client_addr = clients_by_name['redis-py-c2'].get('addr') assert r.client_kill(client_addr, target_nodes=node) is True @@ -2374,8 +2372,7 @@ def test_asking_error(self, r): warnings.warn("skipping this test since the cluster has only one " "node") return - ask_msg = "{0} {1}:{2}".format(r.keyslot(key), ask_node.host, - ask_node.port) + ask_msg = f"{r.keyslot(key)} {ask_node.host}:{ask_node.port}" def raise_ask_error(): raise AskError(ask_msg) @@ -2435,9 +2432,7 @@ def test_moved_redirection_on_slave_with_default(self, r): with r.pipeline() as readwrite_pipe: mock_node_resp(primary, "MOCK_FOO") if replica is not None: - moved_error = "{0} {1}:{2}".format(r.keyslot(key), - primary.host, - primary.port) + moved_error = f"{r.keyslot(key)} {primary.host}:{primary.port}" def raise_moved_error(): raise MovedError(moved_error) diff --git a/tests/test_commands.py b/tests/test_commands.py index f526ae5dd6..444a163489 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -35,7 +35,7 @@ def cleanup(): def redis_server_time(client): seconds, milliseconds = client.time() - timestamp = float('%s.%s' % (seconds, milliseconds)) + timestamp = float(f'{seconds}.{milliseconds}') return datetime.datetime.fromtimestamp(timestamp) @@ -99,7 +99,7 @@ def teardown(): assert r.acl_deluser(username) == 1 # now, a group of users - users = ['bogususer_%d' % r for r in range(0, 5)] + users = [f'bogususer_{r}' for r in range(0, 5)] for u in users: r.acl_setuser(u, enabled=False, reset=True) assert r.acl_deluser(*users) > 1 @@ -162,11 +162,11 @@ def teardown(): commands=['+get', '+mget', '-hset'], keys=['cache:*', 'objects:*']) acl = r.acl_getuser(username) - assert set(acl['categories']) == set(['-@all', '+@set', '+@hash']) - assert set(acl['commands']) == set(['+get', '+mget', '-hset']) + assert set(acl['categories']) == {'-@all', '+@set', '+@hash'} + assert set(acl['commands']) == {'+get', '+mget', '-hset'} assert acl['enabled'] is True assert 'on' in acl['flags'] - assert set(acl['keys']) == set([b'cache:*', b'objects:*']) + assert set(acl['keys']) == {b'cache:*', b'objects:*'} assert len(acl['passwords']) == 2 # test reset=False keeps existing ACL and applies new ACL on top @@ -181,11 +181,11 @@ def teardown(): commands=['+mget'], keys=['objects:*']) acl = r.acl_getuser(username) - assert set(acl['categories']) == set(['-@all', '+@set', '+@hash']) - assert set(acl['commands']) == set(['+get', '+mget']) + assert set(acl['categories']) == {'-@all', '+@set', '+@hash'} + assert set(acl['commands']) == {'+get', '+mget'} assert acl['enabled'] is True assert 'on' in acl['flags'] - assert set(acl['keys']) == set([b'cache:*', b'objects:*']) + assert set(acl['keys']) == {b'cache:*', b'objects:*'} assert len(acl['passwords']) == 2 # test removal of passwords @@ -405,8 +405,7 @@ def test_client_kill(self, r, r2): if client.get('name') in ['redis-py-c1', 'redis-py-c2']] assert len(clients) == 2 - clients_by_name = dict([(client.get('name'), client) - for client in clients]) + clients_by_name = {client.get('name'): client for client in clients} client_addr = clients_by_name['redis-py-c2'].get('addr') assert r.client_kill(client_addr) is True @@ -439,8 +438,7 @@ def test_client_kill_filter_by_id(self, r, r2): if client.get('name') in ['redis-py-c1', 'redis-py-c2']] assert len(clients) == 2 - clients_by_name = dict([(client.get('name'), client) - for client in clients]) + clients_by_name = {client.get('name'): client for client in clients} client_2_id = clients_by_name['redis-py-c2'].get('id') resp = r.client_kill_filter(_id=client_2_id) @@ -460,8 +458,7 @@ def test_client_kill_filter_by_addr(self, r, r2): if client.get('name') in ['redis-py-c1', 'redis-py-c2']] assert len(clients) == 2 - clients_by_name = dict([(client.get('name'), client) - for client in clients]) + clients_by_name = {client.get('name'): client for client in clients} client_2_addr = clients_by_name['redis-py-c2'].get('addr') resp = r.client_kill_filter(addr=client_2_addr) @@ -487,8 +484,7 @@ def test_client_kill_filter_by_laddr(self, r, r2): if client.get('name') in ['redis-py-c1', 'redis-py-c2']] assert len(clients) == 2 - clients_by_name = dict([(client.get('name'), client) - for client in clients]) + clients_by_name = {client.get('name'): client for client in clients} client_2_addr = clients_by_name['redis-py-c2'].get('laddr') assert r.client_kill_filter(laddr=client_2_addr) @@ -1809,11 +1805,11 @@ def test_zadd_incr_with_xx(self, r): def test_zadd_gt_lt(self, r): for i in range(1, 20): - r.zadd('a', {'a%s' % i: i}) + r.zadd('a', {f'a{i}': i}) assert r.zadd('a', {'a20': 5}, gt=3) == 1 for i in range(1, 20): - r.zadd('a', {'a%s' % i: i}) + r.zadd('a', {f'a{i}': i}) assert r.zadd('a', {'a2': 5}, lt=1) == 0 # cannot use both nx and xx options diff --git a/tests/test_connection.py b/tests/test_connection.py index cd8907d85c..0071acab5c 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -15,7 +15,7 @@ def test_invalid_response(r): with mock.patch.object(parser._buffer, 'readline', return_value=raw): with pytest.raises(InvalidResponse) as cm: parser.read_response() - assert str(cm.value) == 'Protocol Error: %r' % raw + assert str(cm.value) == f'Protocol Error: {raw!r}' @skip_if_server_version_lt('4.0.0') diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index d0feef155f..5968b2b4fe 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -89,9 +89,7 @@ def test_pool(self, max_connections, master_host): A child will create its own connections when using a pool created by a parent. """ - pool = ConnectionPool.from_url('redis://{}:{}'.format(master_host[0], - master_host[1], - ), + pool = ConnectionPool.from_url(f'redis://{master_host[0]}:{master_host[1]}', max_connections=max_connections) conn = pool.get_connection('ping') @@ -126,8 +124,7 @@ def test_close_pool_in_main(self, max_connections, master_host): A child process that uses the same pool as its parent isn't affected when the parent disconnects all connections within the pool. """ - pool = ConnectionPool.from_url('redis://{}:{}'.format(master_host[0], - master_host[1]), + pool = ConnectionPool.from_url(f'redis://{master_host[0]}:{master_host[1]}', max_connections=max_connections) conn = pool.get_connection('ping') diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index a759bc944e..a87ed7182d 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -345,7 +345,7 @@ def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r): with pytest.raises(redis.ResponseError) as ex: pipe.execute() - expected = 'Command # 1 (LLEN %s) of pipeline caused error: ' % key + expected = f'Command # 1 (LLEN {key}) of pipeline caused error: ' assert str(ex.value).startswith(expected) assert r[key] == b'1' diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 95513a09a8..b019bae6e2 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -55,7 +55,7 @@ def make_subscribe_test_data(pubsub, type): 'unsub_func': pubsub.punsubscribe, 'keys': ['f*', 'b*', 'uni' + chr(4456) + '*'] } - assert False, 'invalid subscribe type: %s' % type + assert False, f'invalid subscribe type: {type}' class TestPubSubSubscribeUnsubscribe: diff --git a/tests/test_search.py b/tests/test_search.py index b65ac8dcbf..c7b570cdd1 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -99,7 +99,7 @@ def createIndex(client, num_docs=100, definition=None): play, chapter, _, text = \ line[1], line[2], line[4], line[5] - key = "{}:{}".format(play, chapter).lower() + key = f"{play}:{chapter}".lower() d = chapters.setdefault(key, {}) d["play"] = play d["txt"] = d.get("txt", "") + " " + text @@ -861,7 +861,7 @@ def test_phonetic_matcher(client): res = client.ft().search(Query("Jon")) assert 2 == len(res.docs) - assert ["John", "Jon"] == sorted([d.name for d in res.docs]) + assert ["John", "Jon"] == sorted(d.name for d in res.docs) @pytest.mark.redismod diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index c0fb09e226..07433574f1 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -31,7 +31,7 @@ def test_create(client): def test_create_duplicate_policy(client): # Test for duplicate policy for duplicate_policy in ["block", "last", "first", "min", "max"]: - ts_name = "time-serie-ooo-{0}".format(duplicate_policy) + ts_name = f"time-serie-ooo-{duplicate_policy}" assert client.ts().create(ts_name, duplicate_policy=duplicate_policy) info = client.ts().info(ts_name) assert duplicate_policy == info.duplicate_policy diff --git a/tox.ini b/tox.ini index dd68274f2d..d06f7e3d1e 100644 --- a/tox.ini +++ b/tox.ini @@ -130,8 +130,9 @@ commands = /usr/bin/echo deps_files = dev_requirements.txt docker = commands = - flake8 + flake8 --max-line-length=88 vulture redis whitelist.py --min-confidence 80 + flynt --fail-on-change --dry-run . skipsdist = true skip_install = true From 3da0b131ac52b82a1e4a90871a81a41a0ff63a9f Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 30 Nov 2021 17:21:22 +0200 Subject: [PATCH 0276/1164] Updating cluster docker location (#1760) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index d06f7e3d1e..f710bbaca8 100644 --- a/tox.ini +++ b/tox.ini @@ -78,7 +78,7 @@ volumes = [docker:redis_cluster] name = redis_cluster -image = barshaul/redis-py:6.2.6-cluster +image = redisfab/redis-py-cluster:6.2.6-buster ports = 16379:16379/tcp 16380:16380/tcp From d5247091464f91f06d8ca71bb785b448a0d4cc3e Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 30 Nov 2021 17:22:19 +0200 Subject: [PATCH 0277/1164] Link Documents for all module commands (#1711) --- docs/index.rst | 2 +- redis/commands/json/commands.py | 86 ++++++++++++++----- redis/commands/search/commands.py | 118 +++++++++++++++++--------- redis/commands/timeseries/commands.py | 62 ++++++++------ 4 files changed, 181 insertions(+), 87 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 392acadf5b..8e243f3e28 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -73,4 +73,4 @@ Contributing License ******* -This projectis licensed under the `MIT license `_. \ No newline at end of file +This projectis licensed under the `MIT license `_. diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py index 4436f6aa34..1affaafaf6 100644 --- a/redis/commands/json/commands.py +++ b/redis/commands/json/commands.py @@ -10,7 +10,9 @@ class JSONCommands: def arrappend(self, name, path=Path.rootPath(), *args): """Append the objects ``args`` to the array under the ``path` in key ``name``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonarrappend + """ # noqa pieces = [name, str(path)] for o in args: pieces.append(self._encode(o)) @@ -23,7 +25,9 @@ def arrindex(self, name, path, scalar, start=0, stop=-1): The search can be limited using the optional inclusive ``start`` and exclusive ``stop`` indices. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonarrindex + """ # noqa return self.execute_command( "JSON.ARRINDEX", name, str(path), self._encode(scalar), start, stop @@ -32,7 +36,9 @@ def arrindex(self, name, path, scalar, start=0, stop=-1): def arrinsert(self, name, path, index, *args): """Insert the objects ``args`` to the array at index ``index`` under the ``path` in key ``name``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonarrinsert + """ # noqa pieces = [name, str(path), index] for o in args: pieces.append(self._encode(o)) @@ -41,45 +47,64 @@ def arrinsert(self, name, path, index, *args): def arrlen(self, name, path=Path.rootPath()): """Return the length of the array JSON value under ``path`` at key``name``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonarrlen + """ # noqa return self.execute_command("JSON.ARRLEN", name, str(path)) def arrpop(self, name, path=Path.rootPath(), index=-1): """Pop the element at ``index`` in the array JSON value under ``path`` at key ``name``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonarrpop + """ # noqa return self.execute_command("JSON.ARRPOP", name, str(path), index) def arrtrim(self, name, path, start, stop): """Trim the array JSON value under ``path`` at key ``name`` to the inclusive range given by ``start`` and ``stop``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonarrtrim + """ # noqa return self.execute_command("JSON.ARRTRIM", name, str(path), start, stop) def type(self, name, path=Path.rootPath()): - """Get the type of the JSON value under ``path`` from key ``name``.""" + """Get the type of the JSON value under ``path`` from key ``name``. + + For more information: https://oss.redis.com/redisjson/commands/#jsontype + """ # noqa return self.execute_command("JSON.TYPE", name, str(path)) def resp(self, name, path=Path.rootPath()): - """Return the JSON value under ``path`` at key ``name``.""" + """Return the JSON value under ``path`` at key ``name``. + + For more information: https://oss.redis.com/redisjson/commands/#jsonresp + """ # noqa return self.execute_command("JSON.RESP", name, str(path)) def objkeys(self, name, path=Path.rootPath()): """Return the key names in the dictionary JSON value under ``path`` at - key ``name``.""" + key ``name``. + + For more information: https://oss.redis.com/redisjson/commands/#jsonobjkeys + """ # noqa return self.execute_command("JSON.OBJKEYS", name, str(path)) def objlen(self, name, path=Path.rootPath()): """Return the length of the dictionary JSON value under ``path`` at key ``name``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonobjlen + """ # noqa return self.execute_command("JSON.OBJLEN", name, str(path)) def numincrby(self, name, path, number): """Increment the numeric (integer or floating point) JSON value under ``path`` at key ``name`` by the provided ``number``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonnumincrby + """ # noqa return self.execute_command( "JSON.NUMINCRBY", name, str(path), self._encode(number) ) @@ -88,7 +113,9 @@ def numincrby(self, name, path, number): def nummultby(self, name, path, number): """Multiply the numeric (integer or floating point) JSON value under ``path`` at key ``name`` with the provided ``number``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonnummultby + """ # noqa return self.execute_command( "JSON.NUMMULTBY", name, str(path), self._encode(number) ) @@ -100,11 +127,16 @@ def clear(self, name, path=Path.rootPath()): Return the count of cleared paths (ignoring non-array and non-objects paths). - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonclear + """ # noqa return self.execute_command("JSON.CLEAR", name, str(path)) def delete(self, key, path=Path.rootPath()): - """Delete the JSON value stored at key ``key`` under ``path``.""" + """Delete the JSON value stored at key ``key`` under ``path``. + + For more information: https://oss.redis.com/redisjson/commands/#jsondel + """ return self.execute_command("JSON.DEL", key, str(path)) # forget is an alias for delete @@ -117,7 +149,9 @@ def get(self, name, *args, no_escape=False): ``args`` is zero or more paths, and defaults to root path ```no_escape`` is a boolean flag to add no_escape option to get non-ascii characters - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonget + """ # noqa pieces = [name] if no_escape: pieces.append("noescape") @@ -140,7 +174,9 @@ def mget(self, keys, path): """ Get the objects stored as a JSON values under ``path``. ``keys`` is a list of one or more keys. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonmget + """ # noqa pieces = [] pieces += keys pieces.append(str(path)) @@ -157,6 +193,8 @@ def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): For the purpose of using this within a pipeline, this command is also aliased to jsonset. + + For more information: https://oss.redis.com/redisjson/commands/#jsonset """ if decode_keys: obj = decode_dict_keys(obj) @@ -178,7 +216,9 @@ def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): def strlen(self, name, path=None): """Return the length of the string JSON value under ``path`` at key ``name``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonstrlen + """ # noqa pieces = [name] if path is not None: pieces.append(str(path)) @@ -187,14 +227,18 @@ def strlen(self, name, path=None): def toggle(self, name, path=Path.rootPath()): """Toggle boolean value under ``path`` at key ``name``. returning the new value. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsontoggle + """ # noqa return self.execute_command("JSON.TOGGLE", name, str(path)) def strappend(self, name, value, path=Path.rootPath()): """Append to the string JSON value. If two options are specified after the key name, the path is determined to be the first. If a single option is passed, then the rootpath (i.e Path.rootPath()) is used. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsonstrappend + """ # noqa pieces = [name, str(path), self._encode(value)] return self.execute_command( "JSON.STRAPPEND", *pieces @@ -203,7 +247,9 @@ def strappend(self, name, value, path=Path.rootPath()): def debug(self, subcommand, key=None, path=Path.rootPath()): """Return the memory usage in bytes of a value under ``path`` from key ``name``. - """ + + For more information: https://oss.redis.com/redisjson/commands/#jsondebg + """ # noqa valid_subcommands = ["MEMORY", "HELP"] if subcommand not in valid_subcommands: raise DataError("The only valid subcommands are ", diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py index c19cb93e53..553bc39839 100644 --- a/redis/commands/search/commands.py +++ b/redis/commands/search/commands.py @@ -79,7 +79,9 @@ def create_index( allow searching in specific fields - **stopwords**: If not None, we create the index with this custom stopword list. The list can be empty - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftcreate + """ # noqa args = [CREATE_CMD, self.index_name] if definition is not None: @@ -109,7 +111,9 @@ def alter_schema_add(self, fields): ### Parameters: - **fields**: a list of Field objects to add for the index - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftalter_schema_add + """ # noqa args = [ALTER_CMD, self.index_name, "SCHEMA", "ADD"] try: @@ -119,17 +123,6 @@ def alter_schema_add(self, fields): return self.execute_command(*args) - def drop_index(self, delete_documents=True): - """ - Drop the index if it exists. Deprecated from RediSearch 2.0. - - ### Parameters: - - - **delete_documents**: If `True`, all documents will be deleted. - """ - keep_str = "" if delete_documents else "KEEPDOCS" - return self.execute_command(DROP_CMD, self.index_name, keep_str) - def dropindex(self, delete_documents=False): """ Drop the index if it exists. @@ -139,7 +132,8 @@ def dropindex(self, delete_documents=False): ### Parameters: - **delete_documents**: If `True`, all documents will be deleted. - """ + For more information: https://oss.redis.com/redisearch/Commands/#ftdropindex + """ # noqa keep_str = "" if delete_documents else "KEEPDOCS" return self.execute_command(DROP_CMD, self.index_name, keep_str) @@ -246,7 +240,9 @@ def add_document( - **fields** kwargs dictionary of the document fields to be saved and/or indexed. NOTE: Geo points shoule be encoded as strings of "lon,lat" - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftadd + """ # noqa return self._add_document( doc_id, conn=None, @@ -278,7 +274,9 @@ def add_document_hash( - **replace**: if True, and the document already is in the index, we perform an update and reindex the document - **language**: Specify the language used for document tokenization. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftaddhash + """ # noqa return self._add_document_hash( doc_id, conn=None, @@ -296,7 +294,9 @@ def delete_document(self, doc_id, conn=None, delete_actual_document=False): - **delete_actual_document**: if set to True, RediSearch also delete the actual document if it is in the index - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftdel + """ # noqa args = [DEL_CMD, self.index_name, doc_id] if conn is None: conn = self.client @@ -327,6 +327,8 @@ def get(self, *ids): ### Parameters - **ids**: the ids of the saved documents. + + For more information https://oss.redis.com/redisearch/Commands/#ftget """ return self.client.execute_command(MGET_CMD, self.index_name, *ids) @@ -335,6 +337,8 @@ def info(self): """ Get info an stats about the the current index, including the number of documents, memory consumption, etc + + For more information https://oss.redis.com/redisearch/Commands/#ftinfo """ res = self.client.execute_command(INFO_CMD, self.index_name) @@ -362,7 +366,9 @@ def search(self, query): - **query**: the search query. Either a text for simple queries with default parameters, or a Query object for complex queries. See RediSearch's documentation on query format - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftsearch + """ # noqa args, query = self._mk_query_args(query) st = time.time() res = self.execute_command(SEARCH_CMD, *args) @@ -376,6 +382,10 @@ def search(self, query): ) def explain(self, query): + """Returns the execution plan for a complex query. + + For more information: https://oss.redis.com/redisearch/Commands/#ftexplain + """ # noqa args, query_text = self._mk_query_args(query) return self.execute_command(EXPLAIN_CMD, *args) @@ -392,7 +402,9 @@ def aggregate(self, query): An `AggregateResult` object is returned. You can access the rows from its `rows` property, which will always yield the rows of the result. - """ + + Fpr more information: https://oss.redis.com/redisearch/Commands/#ftaggregate + """ # noqa if isinstance(query, AggregateRequest): has_cursor = bool(query._cursor) cmd = [AGGREGATE_CMD, self.index_name] + query.build_args() @@ -477,7 +489,9 @@ def spellcheck(self, query, distance=None, include=None, exclude=None): suggestions (default: 1, max: 4). **include**: specifies an inclusion custom dictionary. **exclude**: specifies an exclusion custom dictionary. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftspellcheck + """ # noqa cmd = [SPELLCHECK_CMD, self.index_name, query] if distance: cmd.extend(["DISTANCE", distance]) @@ -534,7 +548,9 @@ def dict_add(self, name, *terms): - **name**: Dictionary name. - **terms**: List of items for adding to the dictionary. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftdictadd + """ # noqa cmd = [DICT_ADD_CMD, name] cmd.extend(terms) return self.execute_command(*cmd) @@ -546,7 +562,9 @@ def dict_del(self, name, *terms): - **name**: Dictionary name. - **terms**: List of items for removing from the dictionary. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftdictdel + """ # noqa cmd = [DICT_DEL_CMD, name] cmd.extend(terms) return self.execute_command(*cmd) @@ -557,7 +575,9 @@ def dict_dump(self, name): ### Parameters - **name**: Dictionary name. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftdictdump + """ # noqa cmd = [DICT_DUMP_CMD, name] return self.execute_command(*cmd) @@ -568,7 +588,9 @@ def config_set(self, option, value): - **option**: the name of the configuration option. - **value**: a value for the configuration option. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftconfig + """ # noqa cmd = [CONFIG_CMD, "SET", option, value] raw = self.execute_command(*cmd) return raw == "OK" @@ -579,7 +601,9 @@ def config_get(self, option): ### Parameters - **option**: the name of the configuration option. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftconfig + """ # noqa cmd = [CONFIG_CMD, "GET", option] res = {} raw = self.execute_command(*cmd) @@ -595,7 +619,9 @@ def tagvals(self, tagfield): ### Parameters - **tagfield**: Tag field name - """ + + For more information: https://oss.redis.com/redisearch/Commands/#fttagvals + """ # noqa return self.execute_command(TAGVALS_CMD, self.index_name, tagfield) @@ -606,7 +632,9 @@ def aliasadd(self, alias): ### Parameters - **alias**: Name of the alias to create - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftaliasadd + """ # noqa return self.execute_command(ALIAS_ADD_CMD, alias, self.index_name) @@ -617,7 +645,9 @@ def aliasupdate(self, alias): ### Parameters - **alias**: Name of the alias to create - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftaliasupdate + """ # noqa return self.execute_command(ALIAS_UPDATE_CMD, alias, self.index_name) @@ -628,7 +658,9 @@ def aliasdel(self, alias): ### Parameters - **alias**: Name of the alias to delete - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftaliasdel + """ # noqa return self.execute_command(ALIAS_DEL_CMD, alias) def sugadd(self, key, *suggestions, **kwargs): @@ -637,8 +669,9 @@ def sugadd(self, key, *suggestions, **kwargs): a score and string. If kwargs["increment"] is true and the terms are already in the server's dictionary, we increment their scores. - More information `here `_. # noqa - """ + + For more information: https://oss.redis.com/redisearch/master/Commands/#ftsugadd + """ # noqa # If Transaction is not False it will MULTI/EXEC which will error pipe = self.pipeline(transaction=False) for sug in suggestions: @@ -656,16 +689,18 @@ def sugadd(self, key, *suggestions, **kwargs): def suglen(self, key): """ Return the number of entries in the AutoCompleter index. - More information `here `_. # noqa - """ + + For more information https://oss.redis.com/redisearch/master/Commands/#ftsuglen + """ # noqa return self.execute_command(SUGLEN_COMMAND, key) def sugdel(self, key, string): """ Delete a string from the AutoCompleter index. Returns 1 if the string was found and deleted, 0 otherwise. - More information `here `_. # noqa - """ + + For more information: https://oss.redis.com/redisearch/master/Commands/#ftsugdel + """ # noqa return self.execute_command(SUGDEL_COMMAND, key, string) def sugget( @@ -674,7 +709,6 @@ def sugget( ): """ Get a list of suggestions from the AutoCompleter, for a given prefix. - More information `here `_. # noqa Parameters: @@ -701,7 +735,9 @@ def sugget( list: A list of Suggestion objects. If with_scores was False, the score of all suggestions is 1. - """ + + For more information: https://oss.redis.com/redisearch/master/Commands/#ftsugget + """ # noqa args = [SUGGET_COMMAND, key, prefix, "MAX", num] if fuzzy: args.append(FUZZY) @@ -733,7 +769,9 @@ def synupdate(self, groupid, skipinitial=False, *terms): If set to true, we do not scan and index. terms : The terms. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftsynupdate + """ # noqa cmd = [SYNUPDATE_CMD, self.index_name, groupid] if skipinitial: cmd.extend(["SKIPINITIALSCAN"]) @@ -746,6 +784,8 @@ def syndump(self): The command is used to dump the synonyms data structure. Returns a list of synonym terms and their synonym group ids. - """ + + For more information: https://oss.redis.com/redisearch/Commands/#ftsyndump + """ # noqa raw = self.execute_command(SYNDUMP_CMD, self.index_name) return {raw[i]: raw[i + 1] for i in range(0, len(raw), 2)} diff --git a/redis/commands/timeseries/commands.py b/redis/commands/timeseries/commands.py index fcb535b50c..460ba766a9 100644 --- a/redis/commands/timeseries/commands.py +++ b/redis/commands/timeseries/commands.py @@ -26,8 +26,6 @@ class TimeSeriesCommands: def create(self, key, **kwargs): """ Create a new time-series. - For more information see - `TS.CREATE `_. Args: @@ -60,6 +58,8 @@ def create(self, key, **kwargs): - 'min': only override if the value is lower than the existing value. - 'max': only override if the value is higher than the existing value. When this is not set, the server-wide default will be used. + + For more information: https://oss.redis.com/redistimeseries/commands/#tscreate """ # noqa retention_msecs = kwargs.get("retention_msecs", None) uncompressed = kwargs.get("uncompressed", False) @@ -79,9 +79,10 @@ def alter(self, key, **kwargs): """ Update the retention, labels of an existing key. For more information see - `TS.ALTER `_. The parameters are the same as TS.CREATE. + + For more information: https://oss.redis.com/redistimeseries/commands/#tsalter """ # noqa retention_msecs = kwargs.get("retention_msecs", None) labels = kwargs.get("labels", {}) @@ -97,7 +98,6 @@ def add(self, key, timestamp, value, **kwargs): """ Append (or create and append) a new sample to the series. For more information see - `TS.ADD `_. Args: @@ -129,6 +129,8 @@ def add(self, key, timestamp, value, **kwargs): - 'min': only override if the value is lower than the existing value. - 'max': only override if the value is higher than the existing value. When this is not set, the server-wide default will be used. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsadd """ # noqa retention_msecs = kwargs.get("retention_msecs", None) uncompressed = kwargs.get("uncompressed", False) @@ -150,8 +152,8 @@ def madd(self, ktv_tuples): `key` with `timestamp`. Expects a list of `tuples` as (`key`,`timestamp`, `value`). Return value is an array with timestamps of insertions. - For more information see - `TS.MADD `_. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsmadd """ # noqa params = [] for ktv in ktv_tuples: @@ -166,8 +168,6 @@ def incrby(self, key, value, **kwargs): sample's of a series. This command can be used as a counter or gauge that automatically gets history as a time series. - For more information see - `TS.INCRBY `_. Args: @@ -189,6 +189,8 @@ def incrby(self, key, value, **kwargs): chunk_size: Each time-series uses chunks of memory of fixed size for time series samples. You can alter the default TSDB chunk size by passing the chunk_size argument (in Bytes). + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsincrbytsdecrby """ # noqa timestamp = kwargs.get("timestamp", None) retention_msecs = kwargs.get("retention_msecs", None) @@ -210,8 +212,6 @@ def decrby(self, key, value, **kwargs): latest sample's of a series. This command can be used as a counter or gauge that automatically gets history as a time series. - For more information see - `TS.DECRBY `_. Args: @@ -237,6 +237,8 @@ def decrby(self, key, value, **kwargs): chunk_size: Each time-series uses chunks of memory of fixed size for time series samples. You can alter the default TSDB chunk size by passing the chunk_size argument (in Bytes). + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsincrbytsdecrby """ # noqa timestamp = kwargs.get("timestamp", None) retention_msecs = kwargs.get("retention_msecs", None) @@ -260,7 +262,6 @@ def delete(self, key, from_time, to_time): and end data points will also be deleted. Return the count for deleted items. For more information see - `TS.DEL `_. Args: @@ -270,6 +271,8 @@ def delete(self, key, from_time, to_time): Start timestamp for the range deletion. to_time: End timestamp for the range deletion. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsdel """ # noqa return self.execute_command(DEL_CMD, key, from_time, to_time) @@ -285,8 +288,8 @@ def createrule( Aggregating for `bucket_size_msec` where an `aggregation_type` can be [`avg`, `sum`, `min`, `max`, `range`, `count`, `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`] - For more information see - `TS.CREATERULE `_. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tscreaterule """ # noqa params = [source_key, dest_key] self._appendAggregation(params, aggregation_type, bucket_size_msec) @@ -297,7 +300,8 @@ def deleterule(self, source_key, dest_key): """ Delete a compaction rule. For more information see - `TS.DELETERULE `_. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsdeleterule """ # noqa return self.execute_command(DELETERULE_CMD, source_key, dest_key) @@ -343,8 +347,6 @@ def range( ): """ Query a range in forward direction for a specific time-serie. - For more information see - `TS.RANGE `_. Args: @@ -374,6 +376,8 @@ def range( by_min_value). align: Timestamp for alignment control for aggregation. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsrangetsrevrange """ # noqa params = self.__range_params( key, @@ -404,8 +408,6 @@ def revrange( ): """ Query a range in reverse direction for a specific time-series. - For more information see - `TS.REVRANGE `_. **Note**: This command is only available since RedisTimeSeries >= v1.4 @@ -432,6 +434,8 @@ def revrange( Filter result by maximum value (must mention also filter_by_min_value). align: Timestamp for alignment control for aggregation. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsrangetsrevrange """ # noqa params = self.__range_params( key, @@ -500,8 +504,6 @@ def mrange( ): """ Query a range across multiple time-series by filters in forward direction. - For more information see - `TS.MRANGE `_. Args: @@ -544,6 +546,8 @@ def mrange( pair labels of a series. align: Timestamp for alignment control for aggregation. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsmrangetsmrevrange """ # noqa params = self.__mrange_params( aggregation_type, @@ -583,8 +587,6 @@ def mrevrange( ): """ Query a range across multiple time-series by filters in reverse direction. - For more information see - `TS.MREVRANGE `_. Args: @@ -629,6 +631,8 @@ def mrevrange( labels of a series. align: Timestamp for alignment control for aggregation. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsmrangetsmrevrange """ # noqa params = self.__mrange_params( aggregation_type, @@ -652,14 +656,16 @@ def mrevrange( def get(self, key): """ # noqa Get the last sample of `key`. - For more information see `TS.GET `_. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsget """ # noqa return self.execute_command(GET_CMD, key) def mget(self, filters, with_labels=False): """ # noqa Get the last samples matching the specific `filter`. - For more information see `TS.MGET `_. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsmget """ # noqa params = [] self._appendWithLabels(params, with_labels) @@ -670,15 +676,17 @@ def mget(self, filters, with_labels=False): def info(self, key): """ # noqa Get information of `key`. - For more information see `TS.INFO `_. + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsinfo """ # noqa return self.execute_command(INFO_CMD, key) def queryindex(self, filters): """ # noqa Get all the keys matching the `filter` list. - For more information see `TS.QUERYINDEX `_. - """ # noqa + + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsqueryindex + """ # noq return self.execute_command(QUERYINDEX_CMD, *filters) @staticmethod From 368a25f9d163d784a8896f1c087582405e98e006 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 30 Nov 2021 17:30:48 +0200 Subject: [PATCH 0278/1164] Pre-4.1.0rc2 version bump (#1761) --- redis/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/__init__.py b/redis/__init__.py index bc7f3c9d9c..daf741b1c9 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -38,7 +38,7 @@ def int_or_str(value): return value -__version__ = "4.1.0rc1" +__version__ = "4.1.0rc2" VERSION = tuple(map(int_or_str, __version__.split('.'))) From b94e230b17d08e6c89d134e933c706256b79bc4a Mon Sep 17 00:00:00 2001 From: Anas Date: Tue, 30 Nov 2021 18:05:51 +0200 Subject: [PATCH 0279/1164] Added black and isort (#1734) --- benchmarks/base.py | 17 +- benchmarks/basic_operations.py | 70 +- benchmarks/command_packer_benchmark.py | 49 +- benchmarks/socket_read_size.py | 27 +- dev_requirements.txt | 4 +- docs/conf.py | 27 +- redis/__init__.py | 74 +- redis/backoff.py | 2 +- redis/client.py | 1032 +++--- redis/cluster.py | 769 ++-- redis/commands/__init__.py | 12 +- redis/commands/cluster.py | 406 ++- redis/commands/core.py | 1713 +++++---- redis/commands/helpers.py | 12 +- redis/commands/json/__init__.py | 10 +- redis/commands/json/commands.py | 38 +- redis/commands/json/decoders.py | 7 +- redis/commands/parser.py | 41 +- redis/commands/redismodules.py | 16 +- redis/commands/search/__init__.py | 4 +- redis/commands/search/commands.py | 43 +- redis/commands/search/field.py | 6 +- redis/commands/search/query.py | 9 +- redis/commands/search/querystring.py | 7 +- redis/commands/search/result.py | 2 +- redis/commands/search/suggestion.py | 6 +- redis/commands/sentinel.py | 30 +- redis/commands/timeseries/__init__.py | 11 +- redis/commands/timeseries/commands.py | 35 +- redis/commands/timeseries/info.py | 2 +- redis/commands/timeseries/utils.py | 11 +- redis/connection.py | 429 ++- redis/crc.py | 7 +- redis/exceptions.py | 13 +- redis/lock.py | 55 +- redis/retry.py | 5 +- redis/sentinel.py | 136 +- redis/utils.py | 7 +- setup.py | 7 +- tasks.py | 10 +- tests/conftest.py | 168 +- tests/test_cluster.py | 1749 +++++----- tests/test_command_parser.py | 100 +- tests/test_commands.py | 4464 +++++++++++++----------- tests/test_connection.py | 23 +- tests/test_connection_pool.py | 569 +-- tests/test_encoding.py | 81 +- tests/test_helpers.py | 59 +- tests/test_json.py | 106 +- tests/test_lock.py | 117 +- tests/test_monitor.py | 47 +- tests/test_multiprocessing.py | 78 +- tests/test_pipeline.py | 289 +- tests/test_pubsub.py | 340 +- tests/test_retry.py | 7 +- tests/test_scripting.py | 58 +- tests/test_search.py | 410 +-- tests/test_sentinel.py | 120 +- tests/test_timeseries.py | 115 +- tox.ini | 12 +- 60 files changed, 7341 insertions(+), 6732 deletions(-) diff --git a/benchmarks/base.py b/benchmarks/base.py index 519c9ccab5..f52657f072 100644 --- a/benchmarks/base.py +++ b/benchmarks/base.py @@ -1,9 +1,10 @@ import functools import itertools -import redis import sys import timeit +import redis + class Benchmark: ARGUMENTS = () @@ -15,9 +16,7 @@ def get_client(self, **kwargs): # eventually make this more robust and take optional args from # argparse if self._client is None or kwargs: - defaults = { - 'db': 9 - } + defaults = {"db": 9} defaults.update(kwargs) pool = redis.ConnectionPool(**kwargs) self._client = redis.Redis(connection_pool=pool) @@ -30,16 +29,16 @@ def run(self, **kwargs): pass def run_benchmark(self): - group_names = [group['name'] for group in self.ARGUMENTS] - group_values = [group['values'] for group in self.ARGUMENTS] + group_names = [group["name"] for group in self.ARGUMENTS] + group_values = [group["values"] for group in self.ARGUMENTS] for value_set in itertools.product(*group_values): pairs = list(zip(group_names, value_set)) - arg_string = ', '.join(f'{p[0]}={p[1]}' for p in pairs) - sys.stdout.write(f'Benchmark: {arg_string}... ') + arg_string = ", ".join(f"{p[0]}={p[1]}" for p in pairs) + sys.stdout.write(f"Benchmark: {arg_string}... ") sys.stdout.flush() kwargs = dict(pairs) setup = functools.partial(self.setup, **kwargs) run = functools.partial(self.run, **kwargs) t = timeit.timeit(stmt=run, setup=setup, number=1000) - sys.stdout.write(f'{t:f}\n') + sys.stdout.write(f"{t:f}\n") sys.stdout.flush() diff --git a/benchmarks/basic_operations.py b/benchmarks/basic_operations.py index cb009debbd..1dc4a87cc8 100644 --- a/benchmarks/basic_operations.py +++ b/benchmarks/basic_operations.py @@ -1,24 +1,27 @@ -import redis import time -from functools import wraps from argparse import ArgumentParser +from functools import wraps + +import redis def parse_args(): parser = ArgumentParser() - parser.add_argument('-n', - type=int, - help='Total number of requests (default 100000)', - default=100000) - parser.add_argument('-P', - type=int, - help=('Pipeline requests.' - ' Default 1 (no pipeline).'), - default=1) - parser.add_argument('-s', - type=int, - help='Data size of SET/GET value in bytes (default 2)', - default=2) + parser.add_argument( + "-n", type=int, help="Total number of requests (default 100000)", default=100000 + ) + parser.add_argument( + "-P", + type=int, + help=("Pipeline requests." " Default 1 (no pipeline)."), + default=1, + ) + parser.add_argument( + "-s", + type=int, + help="Data size of SET/GET value in bytes (default 2)", + default=2, + ) args = parser.parse_args() return args @@ -45,15 +48,16 @@ def wrapper(*args, **kwargs): start = time.monotonic() ret = func(*args, **kwargs) duration = time.monotonic() - start - if 'num' in kwargs: - count = kwargs['num'] + if "num" in kwargs: + count = kwargs["num"] else: count = args[1] - print(f'{func.__name__} - {count} Requests') - print(f'Duration = {duration}') - print(f'Rate = {count/duration}') + print(f"{func.__name__} - {count} Requests") + print(f"Duration = {duration}") + print(f"Rate = {count/duration}") print() return ret + return wrapper @@ -62,9 +66,9 @@ def set_str(conn, num, pipeline_size, data_size): if pipeline_size > 1: conn = conn.pipeline() - set_data = 'a'.ljust(data_size, '0') + set_data = "a".ljust(data_size, "0") for i in range(num): - conn.set(f'set_str:{i}', set_data) + conn.set(f"set_str:{i}", set_data) if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -79,7 +83,7 @@ def set_int(conn, num, pipeline_size, data_size): set_data = 10 ** (data_size - 1) for i in range(num): - conn.set(f'set_int:{i}', set_data) + conn.set(f"set_int:{i}", set_data) if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -93,7 +97,7 @@ def get_str(conn, num, pipeline_size, data_size): conn = conn.pipeline() for i in range(num): - conn.get(f'set_str:{i}') + conn.get(f"set_str:{i}") if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -107,7 +111,7 @@ def get_int(conn, num, pipeline_size, data_size): conn = conn.pipeline() for i in range(num): - conn.get(f'set_int:{i}') + conn.get(f"set_int:{i}") if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -121,7 +125,7 @@ def incr(conn, num, pipeline_size, *args, **kwargs): conn = conn.pipeline() for i in range(num): - conn.incr('incr_key') + conn.incr("incr_key") if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -136,7 +140,7 @@ def lpush(conn, num, pipeline_size, data_size): set_data = 10 ** (data_size - 1) for i in range(num): - conn.lpush('lpush_key', set_data) + conn.lpush("lpush_key", set_data) if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -150,7 +154,7 @@ def lrange_300(conn, num, pipeline_size, data_size): conn = conn.pipeline() for i in range(num): - conn.lrange('lpush_key', i, i+300) + conn.lrange("lpush_key", i, i + 300) if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -163,7 +167,7 @@ def lpop(conn, num, pipeline_size, data_size): if pipeline_size > 1: conn = conn.pipeline() for i in range(num): - conn.lpop('lpush_key') + conn.lpop("lpush_key") if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() if pipeline_size > 1: @@ -175,11 +179,9 @@ def hmset(conn, num, pipeline_size, data_size): if pipeline_size > 1: conn = conn.pipeline() - set_data = {'str_value': 'string', - 'int_value': 123456, - 'float_value': 123456.0} + set_data = {"str_value": "string", "int_value": 123456, "float_value": 123456.0} for i in range(num): - conn.hmset('hmset_key', set_data) + conn.hmset("hmset_key", set_data) if pipeline_size > 1 and i % pipeline_size == 0: conn.execute() @@ -187,5 +189,5 @@ def hmset(conn, num, pipeline_size, data_size): conn.execute() -if __name__ == '__main__': +if __name__ == "__main__": run() diff --git a/benchmarks/command_packer_benchmark.py b/benchmarks/command_packer_benchmark.py index 3176c06800..e66dbbcbf9 100644 --- a/benchmarks/command_packer_benchmark.py +++ b/benchmarks/command_packer_benchmark.py @@ -1,7 +1,7 @@ -from redis.connection import (Connection, SYM_STAR, SYM_DOLLAR, SYM_EMPTY, - SYM_CRLF) from base import Benchmark +from redis.connection import SYM_CRLF, SYM_DOLLAR, SYM_EMPTY, SYM_STAR, Connection + class StringJoiningConnection(Connection): def send_packed_command(self, command, check_health=True): @@ -13,7 +13,7 @@ def send_packed_command(self, command, check_health=True): except OSError as e: self.disconnect() if len(e.args) == 1: - _errno, errmsg = 'UNKNOWN', e.args[0] + _errno, errmsg = "UNKNOWN", e.args[0] else: _errno, errmsg = e.args raise ConnectionError(f"Error {_errno} while writing to socket. {errmsg}.") @@ -23,12 +23,17 @@ def send_packed_command(self, command, check_health=True): def pack_command(self, *args): "Pack a series of arguments into a value Redis command" - args_output = SYM_EMPTY.join([ - SYM_EMPTY.join( - (SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF, k, SYM_CRLF)) - for k in map(self.encoder.encode, args)]) + args_output = SYM_EMPTY.join( + [ + SYM_EMPTY.join( + (SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF, k, SYM_CRLF) + ) + for k in map(self.encoder.encode, args) + ] + ) output = SYM_EMPTY.join( - (SYM_STAR, str(len(args)).encode(), SYM_CRLF, args_output)) + (SYM_STAR, str(len(args)).encode(), SYM_CRLF, args_output) + ) return output @@ -44,7 +49,7 @@ def send_packed_command(self, command, check_health=True): except OSError as e: self.disconnect() if len(e.args) == 1: - _errno, errmsg = 'UNKNOWN', e.args[0] + _errno, errmsg = "UNKNOWN", e.args[0] else: _errno, errmsg = e.args raise ConnectionError(f"Error {_errno} while writing to socket. {errmsg}.") @@ -54,19 +59,20 @@ def send_packed_command(self, command, check_health=True): def pack_command(self, *args): output = [] - buff = SYM_EMPTY.join( - (SYM_STAR, str(len(args)).encode(), SYM_CRLF)) + buff = SYM_EMPTY.join((SYM_STAR, str(len(args)).encode(), SYM_CRLF)) for k in map(self.encoder.encode, args): if len(buff) > 6000 or len(k) > 6000: buff = SYM_EMPTY.join( - (buff, SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF)) + (buff, SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF) + ) output.append(buff) output.append(k) buff = SYM_CRLF else: - buff = SYM_EMPTY.join((buff, SYM_DOLLAR, str(len(k)).encode(), - SYM_CRLF, k, SYM_CRLF)) + buff = SYM_EMPTY.join( + (buff, SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF, k, SYM_CRLF) + ) output.append(buff) return output @@ -75,13 +81,12 @@ class CommandPackerBenchmark(Benchmark): ARGUMENTS = ( { - 'name': 'connection_class', - 'values': [StringJoiningConnection, ListJoiningConnection] + "name": "connection_class", + "values": [StringJoiningConnection, ListJoiningConnection], }, { - 'name': 'value_size', - 'values': [10, 100, 1000, 10000, 100000, 1000000, 10000000, - 100000000] + "name": "value_size", + "values": [10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000], }, ) @@ -90,9 +95,9 @@ def setup(self, connection_class, value_size): def run(self, connection_class, value_size): r = self.get_client() - x = 'a' * value_size - r.set('benchmark', x) + x = "a" * value_size + r.set("benchmark", x) -if __name__ == '__main__': +if __name__ == "__main__": CommandPackerBenchmark().run_benchmark() diff --git a/benchmarks/socket_read_size.py b/benchmarks/socket_read_size.py index 72a1b0a7e3..3427956ced 100644 --- a/benchmarks/socket_read_size.py +++ b/benchmarks/socket_read_size.py @@ -1,34 +1,27 @@ -from redis.connection import PythonParser, HiredisParser from base import Benchmark +from redis.connection import HiredisParser, PythonParser + class SocketReadBenchmark(Benchmark): ARGUMENTS = ( + {"name": "parser", "values": [PythonParser, HiredisParser]}, { - 'name': 'parser', - 'values': [PythonParser, HiredisParser] - }, - { - 'name': 'value_size', - 'values': [10, 100, 1000, 10000, 100000, 1000000, 10000000, - 100000000] + "name": "value_size", + "values": [10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000], }, - { - 'name': 'read_size', - 'values': [4096, 8192, 16384, 32768, 65536, 131072] - } + {"name": "read_size", "values": [4096, 8192, 16384, 32768, 65536, 131072]}, ) def setup(self, value_size, read_size, parser): - r = self.get_client(parser_class=parser, - socket_read_size=read_size) - r.set('benchmark', 'a' * value_size) + r = self.get_client(parser_class=parser, socket_read_size=read_size) + r.set("benchmark", "a" * value_size) def run(self, value_size, read_size, parser): r = self.get_client() - r.get('benchmark') + r.get("benchmark") -if __name__ == '__main__': +if __name__ == "__main__": SocketReadBenchmark().run_benchmark() diff --git a/dev_requirements.txt b/dev_requirements.txt index 56ac08efe2..2a4f37762f 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,5 +1,7 @@ -flake8>=3.9.2 +black==21.11b1 +flake8==4.0.1 flynt~=0.69.0 +isort==5.10.1 pytest==6.2.5 pytest-timeout==2.0.1 tox==3.24.4 diff --git a/docs/conf.py b/docs/conf.py index 8520969d24..7e83e42156 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,7 +55,8 @@ # # The short X.Y version. import redis -version = '.'.join(redis.__version__.split(".")[0:2]) + +version = ".".join(redis.__version__.split(".")[0:2]) # The full version, including alpha/beta/rc tags. release = redis.__version__ @@ -108,13 +109,13 @@ # further. For a list of options available for each theme, see the # documentation. html_theme_options = { - 'display_version': True, - 'prev_next_buttons_location': 'bottom', - 'style_external_links': False, + "display_version": True, + "prev_next_buttons_location": "bottom", + "style_external_links": False, # Toc options - 'collapse_navigation': True, - 'sticky_navigation': True, - 'navigation_depth': 4, + "collapse_navigation": True, + "sticky_navigation": True, + "navigation_depth": 4, } # Add any paths that contain custom themes here, relative to this directory. @@ -201,11 +202,7 @@ # (source start file, target name, title, author, documentclass # [howto/manual]). latex_documents = [ - ("index", - "redis-py.tex", - "redis-py Documentation", - "Redis Inc", - "manual"), + ("index", "redis-py.tex", "redis-py Documentation", "Redis Inc", "manual"), ] # The name of an image file (relative to this directory) to place at the top of @@ -233,11 +230,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [( - "index", - "redis-py", - "redis-py Documentation", - ["Andy McCurdy"], 1)] +man_pages = [("index", "redis-py", "redis-py Documentation", ["Andy McCurdy"], 1)] # If true, show URL addresses after external links. # man_show_urls = False diff --git a/redis/__init__.py b/redis/__init__.py index daf741b1c9..051b039d06 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -2,18 +2,11 @@ from redis.cluster import RedisCluster from redis.connection import ( BlockingConnectionPool, - ConnectionPool, Connection, + ConnectionPool, SSLConnection, - UnixDomainSocketConnection -) -from redis.sentinel import ( - Sentinel, - SentinelConnectionPool, - SentinelManagedConnection, - SentinelManagedSSLConnection, + UnixDomainSocketConnection, ) -from redis.utils import from_url from redis.exceptions import ( AuthenticationError, AuthenticationWrongNumberOfArgsError, @@ -27,8 +20,15 @@ RedisError, ResponseError, TimeoutError, - WatchError + WatchError, +) +from redis.sentinel import ( + Sentinel, + SentinelConnectionPool, + SentinelManagedConnection, + SentinelManagedSSLConnection, ) +from redis.utils import from_url def int_or_str(value): @@ -41,33 +41,33 @@ def int_or_str(value): __version__ = "4.1.0rc2" -VERSION = tuple(map(int_or_str, __version__.split('.'))) +VERSION = tuple(map(int_or_str, __version__.split("."))) __all__ = [ - 'AuthenticationError', - 'AuthenticationWrongNumberOfArgsError', - 'BlockingConnectionPool', - 'BusyLoadingError', - 'ChildDeadlockedError', - 'Connection', - 'ConnectionError', - 'ConnectionPool', - 'DataError', - 'from_url', - 'InvalidResponse', - 'PubSubError', - 'ReadOnlyError', - 'Redis', - 'RedisCluster', - 'RedisError', - 'ResponseError', - 'Sentinel', - 'SentinelConnectionPool', - 'SentinelManagedConnection', - 'SentinelManagedSSLConnection', - 'SSLConnection', - 'StrictRedis', - 'TimeoutError', - 'UnixDomainSocketConnection', - 'WatchError', + "AuthenticationError", + "AuthenticationWrongNumberOfArgsError", + "BlockingConnectionPool", + "BusyLoadingError", + "ChildDeadlockedError", + "Connection", + "ConnectionError", + "ConnectionPool", + "DataError", + "from_url", + "InvalidResponse", + "PubSubError", + "ReadOnlyError", + "Redis", + "RedisCluster", + "RedisError", + "ResponseError", + "Sentinel", + "SentinelConnectionPool", + "SentinelManagedConnection", + "SentinelManagedSSLConnection", + "SSLConnection", + "StrictRedis", + "TimeoutError", + "UnixDomainSocketConnection", + "WatchError", ] diff --git a/redis/backoff.py b/redis/backoff.py index 9162778cc0..cbb4e73779 100644 --- a/redis/backoff.py +++ b/redis/backoff.py @@ -1,5 +1,5 @@ -from abc import ABC, abstractmethod import random +from abc import ABC, abstractmethod class AbstractBackoff(ABC): diff --git a/redis/client.py b/redis/client.py index 9f2907ee52..14e588a1d7 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1,15 +1,18 @@ -from itertools import chain import copy import datetime import re import threading import time import warnings -from redis.commands import (CoreCommands, RedisModuleCommands, - SentinelCommands, list_or_args) -from redis.connection import (ConnectionPool, UnixDomainSocketConnection, - SSLConnection) -from redis.lock import Lock +from itertools import chain + +from redis.commands import ( + CoreCommands, + RedisModuleCommands, + SentinelCommands, + list_or_args, +) +from redis.connection import ConnectionPool, SSLConnection, UnixDomainSocketConnection from redis.exceptions import ( ConnectionError, ExecAbortError, @@ -20,13 +23,14 @@ TimeoutError, WatchError, ) +from redis.lock import Lock from redis.utils import safe_str, str_if_bytes -SYM_EMPTY = b'' -EMPTY_RESPONSE = 'EMPTY_RESPONSE' +SYM_EMPTY = b"" +EMPTY_RESPONSE = "EMPTY_RESPONSE" # some responses (ie. dump) are binary, and just meant to never be decoded -NEVER_DECODE = 'NEVER_DECODE' +NEVER_DECODE = "NEVER_DECODE" def timestamp_to_datetime(response): @@ -76,12 +80,12 @@ def parse_debug_object(response): # The 'type' of the object is the first item in the response, but isn't # prefixed with a name response = str_if_bytes(response) - response = 'type:' + response - response = dict(kv.split(':') for kv in response.split()) + response = "type:" + response + response = dict(kv.split(":") for kv in response.split()) # parse some expected int values from the string response # note: this cmd isn't spec'd so these may not appear in all redis versions - int_fields = ('refcount', 'serializedlength', 'lru', 'lru_seconds_idle') + int_fields = ("refcount", "serializedlength", "lru", "lru_seconds_idle") for field in int_fields: if field in response: response[field] = int(response[field]) @@ -91,7 +95,7 @@ def parse_debug_object(response): def parse_object(response, infotype): "Parse the results of an OBJECT command" - if infotype in ('idletime', 'refcount'): + if infotype in ("idletime", "refcount"): return int_or_none(response) return response @@ -102,9 +106,9 @@ def parse_info(response): response = str_if_bytes(response) def get_value(value): - if ',' not in value or '=' not in value: + if "," not in value or "=" not in value: try: - if '.' in value: + if "." in value: return float(value) else: return int(value) @@ -112,82 +116,84 @@ def get_value(value): return value else: sub_dict = {} - for item in value.split(','): - k, v = item.rsplit('=', 1) + for item in value.split(","): + k, v = item.rsplit("=", 1) sub_dict[k] = get_value(v) return sub_dict for line in response.splitlines(): - if line and not line.startswith('#'): - if line.find(':') != -1: + if line and not line.startswith("#"): + if line.find(":") != -1: # Split, the info fields keys and values. # Note that the value may contain ':'. but the 'host:' # pseudo-command is the only case where the key contains ':' - key, value = line.split(':', 1) - if key == 'cmdstat_host': - key, value = line.rsplit(':', 1) + key, value = line.split(":", 1) + if key == "cmdstat_host": + key, value = line.rsplit(":", 1) - if key == 'module': + if key == "module": # Hardcode a list for key 'modules' since there could be # multiple lines that started with 'module' - info.setdefault('modules', []).append(get_value(value)) + info.setdefault("modules", []).append(get_value(value)) else: info[key] = get_value(value) else: # if the line isn't splittable, append it to the "__raw__" key - info.setdefault('__raw__', []).append(line) + info.setdefault("__raw__", []).append(line) return info def parse_memory_stats(response, **kwargs): "Parse the results of MEMORY STATS" - stats = pairs_to_dict(response, - decode_keys=True, - decode_string_values=True) + stats = pairs_to_dict(response, decode_keys=True, decode_string_values=True) for key, value in stats.items(): - if key.startswith('db.'): - stats[key] = pairs_to_dict(value, - decode_keys=True, - decode_string_values=True) + if key.startswith("db."): + stats[key] = pairs_to_dict( + value, decode_keys=True, decode_string_values=True + ) return stats SENTINEL_STATE_TYPES = { - 'can-failover-its-master': int, - 'config-epoch': int, - 'down-after-milliseconds': int, - 'failover-timeout': int, - 'info-refresh': int, - 'last-hello-message': int, - 'last-ok-ping-reply': int, - 'last-ping-reply': int, - 'last-ping-sent': int, - 'master-link-down-time': int, - 'master-port': int, - 'num-other-sentinels': int, - 'num-slaves': int, - 'o-down-time': int, - 'pending-commands': int, - 'parallel-syncs': int, - 'port': int, - 'quorum': int, - 'role-reported-time': int, - 's-down-time': int, - 'slave-priority': int, - 'slave-repl-offset': int, - 'voted-leader-epoch': int + "can-failover-its-master": int, + "config-epoch": int, + "down-after-milliseconds": int, + "failover-timeout": int, + "info-refresh": int, + "last-hello-message": int, + "last-ok-ping-reply": int, + "last-ping-reply": int, + "last-ping-sent": int, + "master-link-down-time": int, + "master-port": int, + "num-other-sentinels": int, + "num-slaves": int, + "o-down-time": int, + "pending-commands": int, + "parallel-syncs": int, + "port": int, + "quorum": int, + "role-reported-time": int, + "s-down-time": int, + "slave-priority": int, + "slave-repl-offset": int, + "voted-leader-epoch": int, } def parse_sentinel_state(item): result = pairs_to_dict_typed(item, SENTINEL_STATE_TYPES) - flags = set(result['flags'].split(',')) - for name, flag in (('is_master', 'master'), ('is_slave', 'slave'), - ('is_sdown', 's_down'), ('is_odown', 'o_down'), - ('is_sentinel', 'sentinel'), - ('is_disconnected', 'disconnected'), - ('is_master_down', 'master_down')): + flags = set(result["flags"].split(",")) + for name, flag in ( + ("is_master", "master"), + ("is_slave", "slave"), + ("is_sdown", "s_down"), + ("is_odown", "o_down"), + ("is_sentinel", "sentinel"), + ("is_disconnected", "disconnected"), + ("is_master_down", "master_down"), + ): result[name] = flag in flags return result @@ -200,7 +206,7 @@ def parse_sentinel_masters(response): result = {} for item in response: state = parse_sentinel_state(map(str_if_bytes, item)) - result[state['name']] = state + result[state["name"]] = state return result @@ -251,9 +257,9 @@ def zset_score_pairs(response, **options): If ``withscores`` is specified in the options, return the response as a list of (value, score) pairs """ - if not response or not options.get('withscores'): + if not response or not options.get("withscores"): return response - score_cast_func = options.get('score_cast_func', float) + score_cast_func = options.get("score_cast_func", float) it = iter(response) return list(zip(it, map(score_cast_func, it))) @@ -263,9 +269,9 @@ def sort_return_tuples(response, **options): If ``groups`` is specified, return the response as a list of n-element tuples with n being the value found in options['groups'] """ - if not response or not options.get('groups'): + if not response or not options.get("groups"): return response - n = options['groups'] + n = options["groups"] return list(zip(*[response[i::n] for i in range(n)])) @@ -296,34 +302,30 @@ def parse_list_of_dicts(response): def parse_xclaim(response, **options): - if options.get('parse_justid', False): + if options.get("parse_justid", False): return response return parse_stream_list(response) def parse_xautoclaim(response, **options): - if options.get('parse_justid', False): + if options.get("parse_justid", False): return response[1] return parse_stream_list(response[1]) def parse_xinfo_stream(response, **options): data = pairs_to_dict(response, decode_keys=True) - if not options.get('full', False): - first = data['first-entry'] + if not options.get("full", False): + first = data["first-entry"] if first is not None: - data['first-entry'] = (first[0], pairs_to_dict(first[1])) - last = data['last-entry'] + data["first-entry"] = (first[0], pairs_to_dict(first[1])) + last = data["last-entry"] if last is not None: - data['last-entry'] = (last[0], pairs_to_dict(last[1])) + data["last-entry"] = (last[0], pairs_to_dict(last[1])) else: - data['entries'] = { - _id: pairs_to_dict(entry) - for _id, entry in data['entries'] - } - data['groups'] = [ - pairs_to_dict(group, decode_keys=True) - for group in data['groups'] + data["entries"] = {_id: pairs_to_dict(entry) for _id, entry in data["entries"]} + data["groups"] = [ + pairs_to_dict(group, decode_keys=True) for group in data["groups"] ] return data @@ -335,19 +337,19 @@ def parse_xread(response): def parse_xpending(response, **options): - if options.get('parse_detail', False): + if options.get("parse_detail", False): return parse_xpending_range(response) - consumers = [{'name': n, 'pending': int(p)} for n, p in response[3] or []] + consumers = [{"name": n, "pending": int(p)} for n, p in response[3] or []] return { - 'pending': response[0], - 'min': response[1], - 'max': response[2], - 'consumers': consumers + "pending": response[0], + "min": response[1], + "max": response[2], + "consumers": consumers, } def parse_xpending_range(response): - k = ('message_id', 'consumer', 'time_since_delivered', 'times_delivered') + k = ("message_id", "consumer", "time_since_delivered", "times_delivered") return [dict(zip(k, r)) for r in response] @@ -358,13 +360,13 @@ def float_or_none(response): def bool_ok(response): - return str_if_bytes(response) == 'OK' + return str_if_bytes(response) == "OK" def parse_zadd(response, **options): if response is None: return None - if options.get('as_score'): + if options.get("as_score"): return float(response) return int(response) @@ -373,7 +375,7 @@ def parse_client_list(response, **options): clients = [] for c in str_if_bytes(response).splitlines(): # Values might contain '=' - clients.append(dict(pair.split('=', 1) for pair in c.split(' '))) + clients.append(dict(pair.split("=", 1) for pair in c.split(" "))) return clients @@ -393,7 +395,7 @@ def parse_hscan(response, **options): def parse_zscan(response, **options): - score_cast_func = options.get('score_cast_func', float) + score_cast_func = options.get("score_cast_func", float) cursor, r = response it = iter(r) return int(cursor), list(zip(it, map(score_cast_func, it))) @@ -405,23 +407,24 @@ def parse_zmscore(response, **options): def parse_slowlog_get(response, **options): - space = ' ' if options.get('decode_responses', False) else b' ' + space = " " if options.get("decode_responses", False) else b" " def parse_item(item): result = { - 'id': item[0], - 'start_time': int(item[1]), - 'duration': int(item[2]), + "id": item[0], + "start_time": int(item[1]), + "duration": int(item[2]), } # Redis Enterprise injects another entry at index [3], which has # the complexity info (i.e. the value N in case the command has # an O(N) complexity) instead of the command. if isinstance(item[3], list): - result['command'] = space.join(item[3]) + result["command"] = space.join(item[3]) else: - result['complexity'] = item[3] - result['command'] = space.join(item[4]) + result["complexity"] = item[3] + result["command"] = space.join(item[4]) return result + return [parse_item(item) for item in response] @@ -437,42 +440,42 @@ def parse_stralgo(response, **options): When WITHMATCHLEN is given, each array representing a match will also have the length of the match at the beginning of the array. """ - if options.get('len', False): + if options.get("len", False): return int(response) - if options.get('idx', False): - if options.get('withmatchlen', False): - matches = [[(int(match[-1]))] + list(map(tuple, match[:-1])) - for match in response[1]] + if options.get("idx", False): + if options.get("withmatchlen", False): + matches = [ + [(int(match[-1]))] + list(map(tuple, match[:-1])) + for match in response[1] + ] else: - matches = [list(map(tuple, match)) - for match in response[1]] + matches = [list(map(tuple, match)) for match in response[1]] return { str_if_bytes(response[0]): matches, - str_if_bytes(response[2]): int(response[3]) + str_if_bytes(response[2]): int(response[3]), } return str_if_bytes(response) def parse_cluster_info(response, **options): response = str_if_bytes(response) - return dict(line.split(':') for line in response.splitlines() if line) + return dict(line.split(":") for line in response.splitlines() if line) def _parse_node_line(line): - line_items = line.split(' ') - node_id, addr, flags, master_id, ping, pong, epoch, \ - connected = line.split(' ')[:8] - addr = addr.split('@')[0] - slots = [sl.split('-') for sl in line_items[8:]] + line_items = line.split(" ") + node_id, addr, flags, master_id, ping, pong, epoch, connected = line.split(" ")[:8] + addr = addr.split("@")[0] + slots = [sl.split("-") for sl in line_items[8:]] node_dict = { - 'node_id': node_id, - 'flags': flags, - 'master_id': master_id, - 'last_ping_sent': ping, - 'last_pong_rcvd': pong, - 'epoch': epoch, - 'slots': slots, - 'connected': True if connected == 'connected' else False + "node_id": node_id, + "flags": flags, + "master_id": master_id, + "last_ping_sent": ping, + "last_pong_rcvd": pong, + "epoch": epoch, + "slots": slots, + "connected": True if connected == "connected" else False, } return addr, node_dict @@ -492,7 +495,7 @@ def parse_geosearch_generic(response, **options): Parse the response of 'GEOSEARCH', GEORADIUS' and 'GEORADIUSBYMEMBER' commands according to 'withdist', 'withhash' and 'withcoord' labels. """ - if options['store'] or options['store_dist']: + if options["store"] or options["store_dist"]: # `store` and `store_dist` cant be combined # with other command arguments. # relevant to 'GEORADIUS' and 'GEORADIUSBYMEMBER' @@ -503,24 +506,21 @@ def parse_geosearch_generic(response, **options): else: response_list = response - if not options['withdist'] and not options['withcoord'] \ - and not options['withhash']: + if not options["withdist"] and not options["withcoord"] and not options["withhash"]: # just a bunch of places return response_list cast = { - 'withdist': float, - 'withcoord': lambda ll: (float(ll[0]), float(ll[1])), - 'withhash': int + "withdist": float, + "withcoord": lambda ll: (float(ll[0]), float(ll[1])), + "withhash": int, } # zip all output results with each casting function to get # the properly native Python value. f = [lambda x: x] - f += [cast[o] for o in ['withdist', 'withhash', 'withcoord'] if options[o]] - return [ - list(map(lambda fv: fv[0](fv[1]), zip(f, r))) for r in response_list - ] + f += [cast[o] for o in ["withdist", "withhash", "withcoord"] if options[o]] + return [list(map(lambda fv: fv[0](fv[1]), zip(f, r))) for r in response_list] def parse_command(response, **options): @@ -528,12 +528,12 @@ def parse_command(response, **options): for command in response: cmd_dict = {} cmd_name = str_if_bytes(command[0]) - cmd_dict['name'] = cmd_name - cmd_dict['arity'] = int(command[1]) - cmd_dict['flags'] = [str_if_bytes(flag) for flag in command[2]] - cmd_dict['first_key_pos'] = command[3] - cmd_dict['last_key_pos'] = command[4] - cmd_dict['step_count'] = command[5] + cmd_dict["name"] = cmd_name + cmd_dict["arity"] = int(command[1]) + cmd_dict["flags"] = [str_if_bytes(flag) for flag in command[2]] + cmd_dict["first_key_pos"] = command[3] + cmd_dict["last_key_pos"] = command[4] + cmd_dict["step_count"] = command[5] commands[cmd_name] = cmd_dict return commands @@ -545,7 +545,7 @@ def parse_pubsub_numsub(response, **options): def parse_client_kill(response, **options): if isinstance(response, int): return response - return str_if_bytes(response) == 'OK' + return str_if_bytes(response) == "OK" def parse_acl_getuser(response, **options): @@ -554,21 +554,21 @@ def parse_acl_getuser(response, **options): data = pairs_to_dict(response, decode_keys=True) # convert everything but user-defined data in 'keys' to native strings - data['flags'] = list(map(str_if_bytes, data['flags'])) - data['passwords'] = list(map(str_if_bytes, data['passwords'])) - data['commands'] = str_if_bytes(data['commands']) + data["flags"] = list(map(str_if_bytes, data["flags"])) + data["passwords"] = list(map(str_if_bytes, data["passwords"])) + data["commands"] = str_if_bytes(data["commands"]) # split 'commands' into separate 'categories' and 'commands' lists commands, categories = [], [] - for command in data['commands'].split(' '): - if '@' in command: + for command in data["commands"].split(" "): + if "@" in command: categories.append(command) else: commands.append(command) - data['commands'] = commands - data['categories'] = categories - data['enabled'] = 'on' in data['flags'] + data["commands"] = commands + data["categories"] = categories + data["enabled"] = "on" in data["flags"] return data @@ -579,7 +579,7 @@ def parse_acl_log(response, **options): data = [] for log in response: log_data = pairs_to_dict(log, True, True) - client_info = log_data.get('client-info', '') + client_info = log_data.get("client-info", "") log_data["client-info"] = parse_client_info(client_info) # float() is lossy comparing to the "double" in C @@ -602,9 +602,22 @@ def parse_client_info(value): client_info[key] = value # Those fields are defined as int in networking.c - for int_key in {"id", "age", "idle", "db", "sub", "psub", - "multi", "qbuf", "qbuf-free", "obl", - "argv-mem", "oll", "omem", "tot-mem"}: + for int_key in { + "id", + "age", + "idle", + "db", + "sub", + "psub", + "multi", + "qbuf", + "qbuf-free", + "obl", + "argv-mem", + "oll", + "omem", + "tot-mem", + }: client_info[int_key] = int(client_info[int_key]) return client_info @@ -622,11 +635,11 @@ def parse_set_result(response, **options): - BOOL - String when GET argument is used """ - if options.get('get'): + if options.get("get"): # Redis will return a getCommand result. # See `setGenericCommand` in t_string.c return response - return response and str_if_bytes(response) == 'OK' + return response and str_if_bytes(response) == "OK" class Redis(RedisModuleCommands, CoreCommands, SentinelCommands): @@ -641,158 +654,156 @@ class Redis(RedisModuleCommands, CoreCommands, SentinelCommands): configuration, an instance will either use a ConnectionPool, or Connection object to talk to redis. """ + RESPONSE_CALLBACKS = { **string_keys_to_dict( - 'AUTH COPY EXPIRE EXPIREAT PEXPIRE PEXPIREAT ' - 'HEXISTS HMSET LMOVE BLMOVE MOVE ' - 'MSETNX PERSIST PSETEX RENAMENX SISMEMBER SMOVE SETEX SETNX', - bool + "AUTH COPY EXPIRE EXPIREAT PEXPIRE PEXPIREAT " + "HEXISTS HMSET LMOVE BLMOVE MOVE " + "MSETNX PERSIST PSETEX RENAMENX SISMEMBER SMOVE SETEX SETNX", + bool, ), **string_keys_to_dict( - 'BITCOUNT BITPOS DECRBY DEL EXISTS GEOADD GETBIT HDEL HLEN ' - 'HSTRLEN INCRBY LINSERT LLEN LPUSHX PFADD PFCOUNT RPUSHX SADD ' - 'SCARD SDIFFSTORE SETBIT SETRANGE SINTERSTORE SREM STRLEN ' - 'SUNIONSTORE UNLINK XACK XDEL XLEN XTRIM ZCARD ZLEXCOUNT ZREM ' - 'ZREMRANGEBYLEX ZREMRANGEBYRANK ZREMRANGEBYSCORE', - int + "BITCOUNT BITPOS DECRBY DEL EXISTS GEOADD GETBIT HDEL HLEN " + "HSTRLEN INCRBY LINSERT LLEN LPUSHX PFADD PFCOUNT RPUSHX SADD " + "SCARD SDIFFSTORE SETBIT SETRANGE SINTERSTORE SREM STRLEN " + "SUNIONSTORE UNLINK XACK XDEL XLEN XTRIM ZCARD ZLEXCOUNT ZREM " + "ZREMRANGEBYLEX ZREMRANGEBYRANK ZREMRANGEBYSCORE", + int, ), + **string_keys_to_dict("INCRBYFLOAT HINCRBYFLOAT", float), **string_keys_to_dict( - 'INCRBYFLOAT HINCRBYFLOAT', - float + # these return OK, or int if redis-server is >=1.3.4 + "LPUSH RPUSH", + lambda r: isinstance(r, int) and r or str_if_bytes(r) == "OK", ), + **string_keys_to_dict("SORT", sort_return_tuples), + **string_keys_to_dict("ZSCORE ZINCRBY GEODIST", float_or_none), **string_keys_to_dict( - # these return OK, or int if redis-server is >=1.3.4 - 'LPUSH RPUSH', - lambda r: isinstance(r, int) and r or str_if_bytes(r) == 'OK' + "FLUSHALL FLUSHDB LSET LTRIM MSET PFMERGE READONLY READWRITE " + "RENAME SAVE SELECT SHUTDOWN SLAVEOF SWAPDB WATCH UNWATCH ", + bool_ok, ), - **string_keys_to_dict('SORT', sort_return_tuples), - **string_keys_to_dict('ZSCORE ZINCRBY GEODIST', float_or_none), + **string_keys_to_dict("BLPOP BRPOP", lambda r: r and tuple(r) or None), **string_keys_to_dict( - 'FLUSHALL FLUSHDB LSET LTRIM MSET PFMERGE READONLY READWRITE ' - 'RENAME SAVE SELECT SHUTDOWN SLAVEOF SWAPDB WATCH UNWATCH ', - bool_ok + "SDIFF SINTER SMEMBERS SUNION", lambda r: r and set(r) or set() ), - **string_keys_to_dict('BLPOP BRPOP', lambda r: r and tuple(r) or None), **string_keys_to_dict( - 'SDIFF SINTER SMEMBERS SUNION', - lambda r: r and set(r) or set() + "ZPOPMAX ZPOPMIN ZINTER ZDIFF ZUNION ZRANGE ZRANGEBYSCORE " + "ZREVRANGE ZREVRANGEBYSCORE", + zset_score_pairs, ), **string_keys_to_dict( - 'ZPOPMAX ZPOPMIN ZINTER ZDIFF ZUNION ZRANGE ZRANGEBYSCORE ' - 'ZREVRANGE ZREVRANGEBYSCORE', zset_score_pairs + "BZPOPMIN BZPOPMAX", lambda r: r and (r[0], r[1], float(r[2])) or None + ), + **string_keys_to_dict("ZRANK ZREVRANK", int_or_none), + **string_keys_to_dict("XREVRANGE XRANGE", parse_stream_list), + **string_keys_to_dict("XREAD XREADGROUP", parse_xread), + **string_keys_to_dict("BGREWRITEAOF BGSAVE", lambda r: True), + "ACL CAT": lambda r: list(map(str_if_bytes, r)), + "ACL DELUSER": int, + "ACL GENPASS": str_if_bytes, + "ACL GETUSER": parse_acl_getuser, + "ACL HELP": lambda r: list(map(str_if_bytes, r)), + "ACL LIST": lambda r: list(map(str_if_bytes, r)), + "ACL LOAD": bool_ok, + "ACL LOG": parse_acl_log, + "ACL SAVE": bool_ok, + "ACL SETUSER": bool_ok, + "ACL USERS": lambda r: list(map(str_if_bytes, r)), + "ACL WHOAMI": str_if_bytes, + "CLIENT GETNAME": str_if_bytes, + "CLIENT ID": int, + "CLIENT KILL": parse_client_kill, + "CLIENT LIST": parse_client_list, + "CLIENT INFO": parse_client_info, + "CLIENT SETNAME": bool_ok, + "CLIENT UNBLOCK": lambda r: r and int(r) == 1 or False, + "CLIENT PAUSE": bool_ok, + "CLIENT GETREDIR": int, + "CLIENT TRACKINGINFO": lambda r: list(map(str_if_bytes, r)), + "CLUSTER ADDSLOTS": bool_ok, + "CLUSTER COUNT-FAILURE-REPORTS": lambda x: int(x), + "CLUSTER COUNTKEYSINSLOT": lambda x: int(x), + "CLUSTER DELSLOTS": bool_ok, + "CLUSTER FAILOVER": bool_ok, + "CLUSTER FORGET": bool_ok, + "CLUSTER INFO": parse_cluster_info, + "CLUSTER KEYSLOT": lambda x: int(x), + "CLUSTER MEET": bool_ok, + "CLUSTER NODES": parse_cluster_nodes, + "CLUSTER REPLICATE": bool_ok, + "CLUSTER RESET": bool_ok, + "CLUSTER SAVECONFIG": bool_ok, + "CLUSTER SET-CONFIG-EPOCH": bool_ok, + "CLUSTER SETSLOT": bool_ok, + "CLUSTER SLAVES": parse_cluster_nodes, + "CLUSTER REPLICAS": parse_cluster_nodes, + "COMMAND": parse_command, + "COMMAND COUNT": int, + "COMMAND GETKEYS": lambda r: list(map(str_if_bytes, r)), + "CONFIG GET": parse_config_get, + "CONFIG RESETSTAT": bool_ok, + "CONFIG SET": bool_ok, + "DEBUG OBJECT": parse_debug_object, + "GEOHASH": lambda r: list(map(str_if_bytes, r)), + "GEOPOS": lambda r: list( + map(lambda ll: (float(ll[0]), float(ll[1])) if ll is not None else None, r) ), - **string_keys_to_dict('BZPOPMIN BZPOPMAX', \ - lambda r: - r and (r[0], r[1], float(r[2])) or None), - **string_keys_to_dict('ZRANK ZREVRANK', int_or_none), - **string_keys_to_dict('XREVRANGE XRANGE', parse_stream_list), - **string_keys_to_dict('XREAD XREADGROUP', parse_xread), - **string_keys_to_dict('BGREWRITEAOF BGSAVE', lambda r: True), - 'ACL CAT': lambda r: list(map(str_if_bytes, r)), - 'ACL DELUSER': int, - 'ACL GENPASS': str_if_bytes, - 'ACL GETUSER': parse_acl_getuser, - 'ACL HELP': lambda r: list(map(str_if_bytes, r)), - 'ACL LIST': lambda r: list(map(str_if_bytes, r)), - 'ACL LOAD': bool_ok, - 'ACL LOG': parse_acl_log, - 'ACL SAVE': bool_ok, - 'ACL SETUSER': bool_ok, - 'ACL USERS': lambda r: list(map(str_if_bytes, r)), - 'ACL WHOAMI': str_if_bytes, - 'CLIENT GETNAME': str_if_bytes, - 'CLIENT ID': int, - 'CLIENT KILL': parse_client_kill, - 'CLIENT LIST': parse_client_list, - 'CLIENT INFO': parse_client_info, - 'CLIENT SETNAME': bool_ok, - 'CLIENT UNBLOCK': lambda r: r and int(r) == 1 or False, - 'CLIENT PAUSE': bool_ok, - 'CLIENT GETREDIR': int, - 'CLIENT TRACKINGINFO': lambda r: list(map(str_if_bytes, r)), - 'CLUSTER ADDSLOTS': bool_ok, - 'CLUSTER COUNT-FAILURE-REPORTS': lambda x: int(x), - 'CLUSTER COUNTKEYSINSLOT': lambda x: int(x), - 'CLUSTER DELSLOTS': bool_ok, - 'CLUSTER FAILOVER': bool_ok, - 'CLUSTER FORGET': bool_ok, - 'CLUSTER INFO': parse_cluster_info, - 'CLUSTER KEYSLOT': lambda x: int(x), - 'CLUSTER MEET': bool_ok, - 'CLUSTER NODES': parse_cluster_nodes, - 'CLUSTER REPLICATE': bool_ok, - 'CLUSTER RESET': bool_ok, - 'CLUSTER SAVECONFIG': bool_ok, - 'CLUSTER SET-CONFIG-EPOCH': bool_ok, - 'CLUSTER SETSLOT': bool_ok, - 'CLUSTER SLAVES': parse_cluster_nodes, - 'CLUSTER REPLICAS': parse_cluster_nodes, - 'COMMAND': parse_command, - 'COMMAND COUNT': int, - 'COMMAND GETKEYS': lambda r: list(map(str_if_bytes, r)), - 'CONFIG GET': parse_config_get, - 'CONFIG RESETSTAT': bool_ok, - 'CONFIG SET': bool_ok, - 'DEBUG OBJECT': parse_debug_object, - 'GEOHASH': lambda r: list(map(str_if_bytes, r)), - 'GEOPOS': lambda r: list(map(lambda ll: (float(ll[0]), - float(ll[1])) - if ll is not None else None, r)), - 'GEOSEARCH': parse_geosearch_generic, - 'GEORADIUS': parse_geosearch_generic, - 'GEORADIUSBYMEMBER': parse_geosearch_generic, - 'HGETALL': lambda r: r and pairs_to_dict(r) or {}, - 'HSCAN': parse_hscan, - 'INFO': parse_info, - 'LASTSAVE': timestamp_to_datetime, - 'MEMORY PURGE': bool_ok, - 'MEMORY STATS': parse_memory_stats, - 'MEMORY USAGE': int_or_none, - 'MODULE LOAD': parse_module_result, - 'MODULE UNLOAD': parse_module_result, - 'MODULE LIST': lambda r: [pairs_to_dict(m) for m in r], - 'OBJECT': parse_object, - 'PING': lambda r: str_if_bytes(r) == 'PONG', - 'QUIT': bool_ok, - 'STRALGO': parse_stralgo, - 'PUBSUB NUMSUB': parse_pubsub_numsub, - 'RANDOMKEY': lambda r: r and r or None, - 'SCAN': parse_scan, - 'SCRIPT EXISTS': lambda r: list(map(bool, r)), - 'SCRIPT FLUSH': bool_ok, - 'SCRIPT KILL': bool_ok, - 'SCRIPT LOAD': str_if_bytes, - 'SENTINEL CKQUORUM': bool_ok, - 'SENTINEL FAILOVER': bool_ok, - 'SENTINEL FLUSHCONFIG': bool_ok, - 'SENTINEL GET-MASTER-ADDR-BY-NAME': parse_sentinel_get_master, - 'SENTINEL MASTER': parse_sentinel_master, - 'SENTINEL MASTERS': parse_sentinel_masters, - 'SENTINEL MONITOR': bool_ok, - 'SENTINEL RESET': bool_ok, - 'SENTINEL REMOVE': bool_ok, - 'SENTINEL SENTINELS': parse_sentinel_slaves_and_sentinels, - 'SENTINEL SET': bool_ok, - 'SENTINEL SLAVES': parse_sentinel_slaves_and_sentinels, - 'SET': parse_set_result, - 'SLOWLOG GET': parse_slowlog_get, - 'SLOWLOG LEN': int, - 'SLOWLOG RESET': bool_ok, - 'SSCAN': parse_scan, - 'TIME': lambda x: (int(x[0]), int(x[1])), - 'XCLAIM': parse_xclaim, - 'XAUTOCLAIM': parse_xautoclaim, - 'XGROUP CREATE': bool_ok, - 'XGROUP DELCONSUMER': int, - 'XGROUP DESTROY': bool, - 'XGROUP SETID': bool_ok, - 'XINFO CONSUMERS': parse_list_of_dicts, - 'XINFO GROUPS': parse_list_of_dicts, - 'XINFO STREAM': parse_xinfo_stream, - 'XPENDING': parse_xpending, - 'ZADD': parse_zadd, - 'ZSCAN': parse_zscan, - 'ZMSCORE': parse_zmscore, + "GEOSEARCH": parse_geosearch_generic, + "GEORADIUS": parse_geosearch_generic, + "GEORADIUSBYMEMBER": parse_geosearch_generic, + "HGETALL": lambda r: r and pairs_to_dict(r) or {}, + "HSCAN": parse_hscan, + "INFO": parse_info, + "LASTSAVE": timestamp_to_datetime, + "MEMORY PURGE": bool_ok, + "MEMORY STATS": parse_memory_stats, + "MEMORY USAGE": int_or_none, + "MODULE LOAD": parse_module_result, + "MODULE UNLOAD": parse_module_result, + "MODULE LIST": lambda r: [pairs_to_dict(m) for m in r], + "OBJECT": parse_object, + "PING": lambda r: str_if_bytes(r) == "PONG", + "QUIT": bool_ok, + "STRALGO": parse_stralgo, + "PUBSUB NUMSUB": parse_pubsub_numsub, + "RANDOMKEY": lambda r: r and r or None, + "SCAN": parse_scan, + "SCRIPT EXISTS": lambda r: list(map(bool, r)), + "SCRIPT FLUSH": bool_ok, + "SCRIPT KILL": bool_ok, + "SCRIPT LOAD": str_if_bytes, + "SENTINEL CKQUORUM": bool_ok, + "SENTINEL FAILOVER": bool_ok, + "SENTINEL FLUSHCONFIG": bool_ok, + "SENTINEL GET-MASTER-ADDR-BY-NAME": parse_sentinel_get_master, + "SENTINEL MASTER": parse_sentinel_master, + "SENTINEL MASTERS": parse_sentinel_masters, + "SENTINEL MONITOR": bool_ok, + "SENTINEL RESET": bool_ok, + "SENTINEL REMOVE": bool_ok, + "SENTINEL SENTINELS": parse_sentinel_slaves_and_sentinels, + "SENTINEL SET": bool_ok, + "SENTINEL SLAVES": parse_sentinel_slaves_and_sentinels, + "SET": parse_set_result, + "SLOWLOG GET": parse_slowlog_get, + "SLOWLOG LEN": int, + "SLOWLOG RESET": bool_ok, + "SSCAN": parse_scan, + "TIME": lambda x: (int(x[0]), int(x[1])), + "XCLAIM": parse_xclaim, + "XAUTOCLAIM": parse_xautoclaim, + "XGROUP CREATE": bool_ok, + "XGROUP DELCONSUMER": int, + "XGROUP DESTROY": bool, + "XGROUP SETID": bool_ok, + "XINFO CONSUMERS": parse_list_of_dicts, + "XINFO GROUPS": parse_list_of_dicts, + "XINFO STREAM": parse_xinfo_stream, + "XPENDING": parse_xpending, + "ZADD": parse_zadd, + "ZSCAN": parse_zscan, + "ZMSCORE": parse_zmscore, } @classmethod @@ -839,20 +850,38 @@ class initializer. In the case of conflicting arguments, querystring connection_pool = ConnectionPool.from_url(url, **kwargs) return cls(connection_pool=connection_pool) - def __init__(self, host='localhost', port=6379, - db=0, password=None, socket_timeout=None, - socket_connect_timeout=None, - socket_keepalive=None, socket_keepalive_options=None, - connection_pool=None, unix_socket_path=None, - encoding='utf-8', encoding_errors='strict', - charset=None, errors=None, - decode_responses=False, retry_on_timeout=False, - ssl=False, ssl_keyfile=None, ssl_certfile=None, - ssl_cert_reqs='required', ssl_ca_certs=None, - ssl_check_hostname=False, - max_connections=None, single_connection_client=False, - health_check_interval=0, client_name=None, username=None, - retry=None, redis_connect_func=None): + def __init__( + self, + host="localhost", + port=6379, + db=0, + password=None, + socket_timeout=None, + socket_connect_timeout=None, + socket_keepalive=None, + socket_keepalive_options=None, + connection_pool=None, + unix_socket_path=None, + encoding="utf-8", + encoding_errors="strict", + charset=None, + errors=None, + decode_responses=False, + retry_on_timeout=False, + ssl=False, + ssl_keyfile=None, + ssl_certfile=None, + ssl_cert_reqs="required", + ssl_ca_certs=None, + ssl_check_hostname=False, + max_connections=None, + single_connection_client=False, + health_check_interval=0, + client_name=None, + username=None, + retry=None, + redis_connect_func=None, + ): """ Initialize a new Redis client. To specify a retry policy, first set `retry_on_timeout` to `True` @@ -860,62 +889,73 @@ def __init__(self, host='localhost', port=6379, """ if not connection_pool: if charset is not None: - warnings.warn(DeprecationWarning( - '"charset" is deprecated. Use "encoding" instead')) + warnings.warn( + DeprecationWarning( + '"charset" is deprecated. Use "encoding" instead' + ) + ) encoding = charset if errors is not None: - warnings.warn(DeprecationWarning( - '"errors" is deprecated. Use "encoding_errors" instead')) + warnings.warn( + DeprecationWarning( + '"errors" is deprecated. Use "encoding_errors" instead' + ) + ) encoding_errors = errors kwargs = { - 'db': db, - 'username': username, - 'password': password, - 'socket_timeout': socket_timeout, - 'encoding': encoding, - 'encoding_errors': encoding_errors, - 'decode_responses': decode_responses, - 'retry_on_timeout': retry_on_timeout, - 'retry': copy.deepcopy(retry), - 'max_connections': max_connections, - 'health_check_interval': health_check_interval, - 'client_name': client_name, - 'redis_connect_func': redis_connect_func + "db": db, + "username": username, + "password": password, + "socket_timeout": socket_timeout, + "encoding": encoding, + "encoding_errors": encoding_errors, + "decode_responses": decode_responses, + "retry_on_timeout": retry_on_timeout, + "retry": copy.deepcopy(retry), + "max_connections": max_connections, + "health_check_interval": health_check_interval, + "client_name": client_name, + "redis_connect_func": redis_connect_func, } # based on input, setup appropriate connection args if unix_socket_path is not None: - kwargs.update({ - 'path': unix_socket_path, - 'connection_class': UnixDomainSocketConnection - }) + kwargs.update( + { + "path": unix_socket_path, + "connection_class": UnixDomainSocketConnection, + } + ) else: # TCP specific options - kwargs.update({ - 'host': host, - 'port': port, - 'socket_connect_timeout': socket_connect_timeout, - 'socket_keepalive': socket_keepalive, - 'socket_keepalive_options': socket_keepalive_options, - }) + kwargs.update( + { + "host": host, + "port": port, + "socket_connect_timeout": socket_connect_timeout, + "socket_keepalive": socket_keepalive, + "socket_keepalive_options": socket_keepalive_options, + } + ) if ssl: - kwargs.update({ - 'connection_class': SSLConnection, - 'ssl_keyfile': ssl_keyfile, - 'ssl_certfile': ssl_certfile, - 'ssl_cert_reqs': ssl_cert_reqs, - 'ssl_ca_certs': ssl_ca_certs, - 'ssl_check_hostname': ssl_check_hostname, - }) + kwargs.update( + { + "connection_class": SSLConnection, + "ssl_keyfile": ssl_keyfile, + "ssl_certfile": ssl_certfile, + "ssl_cert_reqs": ssl_cert_reqs, + "ssl_ca_certs": ssl_ca_certs, + "ssl_check_hostname": ssl_check_hostname, + } + ) connection_pool = ConnectionPool(**kwargs) self.connection_pool = connection_pool self.connection = None if single_connection_client: - self.connection = self.connection_pool.get_connection('_') + self.connection = self.connection_pool.get_connection("_") - self.response_callbacks = CaseInsensitiveDict( - self.__class__.RESPONSE_CALLBACKS) + self.response_callbacks = CaseInsensitiveDict(self.__class__.RESPONSE_CALLBACKS) def __repr__(self): return f"{type(self).__name__}<{repr(self.connection_pool)}>" @@ -924,8 +964,11 @@ def set_response_callback(self, command, callback): "Set a custom Response Callback" self.response_callbacks[command] = callback - def load_external_module(self, funcname, func, - ): + def load_external_module( + self, + funcname, + func, + ): """ This function can be used to add externally defined redis modules, and their namespaces to the redis client. @@ -957,10 +1000,8 @@ def pipeline(self, transaction=True, shard_hint=None): between the client and server. """ return Pipeline( - self.connection_pool, - self.response_callbacks, - transaction, - shard_hint) + self.connection_pool, self.response_callbacks, transaction, shard_hint + ) def transaction(self, func, *watches, **kwargs): """ @@ -968,9 +1009,9 @@ def transaction(self, func, *watches, **kwargs): while watching all keys specified in `watches`. The 'func' callable should expect a single argument which is a Pipeline object. """ - shard_hint = kwargs.pop('shard_hint', None) - value_from_callable = kwargs.pop('value_from_callable', False) - watch_delay = kwargs.pop('watch_delay', None) + shard_hint = kwargs.pop("shard_hint", None) + value_from_callable = kwargs.pop("value_from_callable", False) + watch_delay = kwargs.pop("watch_delay", None) with self.pipeline(True, shard_hint) as pipe: while True: try: @@ -984,8 +1025,15 @@ def transaction(self, func, *watches, **kwargs): time.sleep(watch_delay) continue - def lock(self, name, timeout=None, sleep=0.1, blocking_timeout=None, - lock_class=None, thread_local=True): + def lock( + self, + name, + timeout=None, + sleep=0.1, + blocking_timeout=None, + lock_class=None, + thread_local=True, + ): """ Return a new Lock object using key ``name`` that mimics the behavior of threading.Lock. @@ -1028,12 +1076,17 @@ def lock(self, name, timeout=None, sleep=0.1, blocking_timeout=None, local storage isn't disabled in this case, the worker thread won't see the token set by the thread that acquired the lock. Our assumption is that these cases aren't common and as such default to using - thread local storage. """ + thread local storage.""" if lock_class is None: lock_class = Lock - return lock_class(self, name, timeout=timeout, sleep=sleep, - blocking_timeout=blocking_timeout, - thread_local=thread_local) + return lock_class( + self, + name, + timeout=timeout, + sleep=sleep, + blocking_timeout=blocking_timeout, + thread_local=thread_local, + ) def pubsub(self, **kwargs): """ @@ -1047,8 +1100,9 @@ def monitor(self): return Monitor(self.connection_pool) def client(self): - return self.__class__(connection_pool=self.connection_pool, - single_connection_client=True) + return self.__class__( + connection_pool=self.connection_pool, single_connection_client=True + ) def __enter__(self): return self @@ -1065,11 +1119,7 @@ def close(self): self.connection = None self.connection_pool.release(conn) - def _send_command_parse_response(self, - conn, - command_name, - *args, - **options): + def _send_command_parse_response(self, conn, command_name, *args, **options): """ Send a command and parse the response """ @@ -1095,11 +1145,11 @@ def execute_command(self, *args, **options): try: return conn.retry.call_with_retry( - lambda: self._send_command_parse_response(conn, - command_name, - *args, - **options), - lambda error: self._disconnect_raise(conn, error)) + lambda: self._send_command_parse_response( + conn, command_name, *args, **options + ), + lambda error: self._disconnect_raise(conn, error), + ) finally: if not self.connection: pool.release(conn) @@ -1129,19 +1179,20 @@ class Monitor: next_command() method returns one command from monitor listen() method yields commands from monitor. """ - monitor_re = re.compile(r'\[(\d+) (.*)\] (.*)') + + monitor_re = re.compile(r"\[(\d+) (.*)\] (.*)") command_re = re.compile(r'"(.*?)(? conn.next_health_check: - conn.send_command('PING', self.HEALTH_CHECK_MESSAGE, - check_health=False) + conn.send_command("PING", self.HEALTH_CHECK_MESSAGE, check_health=False) def _normalize_keys(self, data): """ @@ -1371,7 +1425,7 @@ def psubscribe(self, *args, **kwargs): args = list_or_args(args[0], args[1:]) new_patterns = dict.fromkeys(args) new_patterns.update(kwargs) - ret_val = self.execute_command('PSUBSCRIBE', *new_patterns.keys()) + ret_val = self.execute_command("PSUBSCRIBE", *new_patterns.keys()) # update the patterns dict AFTER we send the command. we don't want to # subscribe twice to these patterns, once for the command and again # for the reconnection. @@ -1391,7 +1445,7 @@ def punsubscribe(self, *args): else: patterns = self.patterns self.pending_unsubscribe_patterns.update(patterns) - return self.execute_command('PUNSUBSCRIBE', *args) + return self.execute_command("PUNSUBSCRIBE", *args) def subscribe(self, *args, **kwargs): """ @@ -1405,7 +1459,7 @@ def subscribe(self, *args, **kwargs): args = list_or_args(args[0], args[1:]) new_channels = dict.fromkeys(args) new_channels.update(kwargs) - ret_val = self.execute_command('SUBSCRIBE', *new_channels.keys()) + ret_val = self.execute_command("SUBSCRIBE", *new_channels.keys()) # update the channels dict AFTER we send the command. we don't want to # subscribe twice to these channels, once for the command and again # for the reconnection. @@ -1425,7 +1479,7 @@ def unsubscribe(self, *args): else: channels = self.channels self.pending_unsubscribe_channels.update(channels) - return self.execute_command('UNSUBSCRIBE', *args) + return self.execute_command("UNSUBSCRIBE", *args) def listen(self): "Listen for messages on channels this client has been subscribed to" @@ -1451,8 +1505,8 @@ def ping(self, message=None): """ Ping the Redis server """ - message = '' if message is None else message - return self.execute_command('PING', message) + message = "" if message is None else message + return self.execute_command("PING", message) def handle_message(self, response, ignore_subscribe_messages=False): """ @@ -1461,31 +1515,31 @@ def handle_message(self, response, ignore_subscribe_messages=False): message being returned. """ message_type = str_if_bytes(response[0]) - if message_type == 'pmessage': + if message_type == "pmessage": message = { - 'type': message_type, - 'pattern': response[1], - 'channel': response[2], - 'data': response[3] + "type": message_type, + "pattern": response[1], + "channel": response[2], + "data": response[3], } - elif message_type == 'pong': + elif message_type == "pong": message = { - 'type': message_type, - 'pattern': None, - 'channel': None, - 'data': response[1] + "type": message_type, + "pattern": None, + "channel": None, + "data": response[1], } else: message = { - 'type': message_type, - 'pattern': None, - 'channel': response[1], - 'data': response[2] + "type": message_type, + "pattern": None, + "channel": response[1], + "data": response[2], } # if this is an unsubscribe message, remove it from memory if message_type in self.UNSUBSCRIBE_MESSAGE_TYPES: - if message_type == 'punsubscribe': + if message_type == "punsubscribe": pattern = response[1] if pattern in self.pending_unsubscribe_patterns: self.pending_unsubscribe_patterns.remove(pattern) @@ -1498,14 +1552,14 @@ def handle_message(self, response, ignore_subscribe_messages=False): if message_type in self.PUBLISH_MESSAGE_TYPES: # if there's a message handler, invoke it - if message_type == 'pmessage': - handler = self.patterns.get(message['pattern'], None) + if message_type == "pmessage": + handler = self.patterns.get(message["pattern"], None) else: - handler = self.channels.get(message['channel'], None) + handler = self.channels.get(message["channel"], None) if handler: handler(message) return None - elif message_type != 'pong': + elif message_type != "pong": # this is a subscribe/unsubscribe message. ignore if we don't # want them if ignore_subscribe_messages or self.ignore_subscribe_messages: @@ -1513,8 +1567,7 @@ def handle_message(self, response, ignore_subscribe_messages=False): return message - def run_in_thread(self, sleep_time=0, daemon=False, - exception_handler=None): + def run_in_thread(self, sleep_time=0, daemon=False, exception_handler=None): for channel, handler in self.channels.items(): if handler is None: raise PubSubError(f"Channel: '{channel}' has no handler registered") @@ -1523,18 +1576,14 @@ def run_in_thread(self, sleep_time=0, daemon=False, raise PubSubError(f"Pattern: '{pattern}' has no handler registered") thread = PubSubWorkerThread( - self, - sleep_time, - daemon=daemon, - exception_handler=exception_handler + self, sleep_time, daemon=daemon, exception_handler=exception_handler ) thread.start() return thread class PubSubWorkerThread(threading.Thread): - def __init__(self, pubsub, sleep_time, daemon=False, - exception_handler=None): + def __init__(self, pubsub, sleep_time, daemon=False, exception_handler=None): super().__init__() self.daemon = daemon self.pubsub = pubsub @@ -1550,8 +1599,7 @@ def run(self): sleep_time = self.sleep_time while self._running.is_set(): try: - pubsub.get_message(ignore_subscribe_messages=True, - timeout=sleep_time) + pubsub.get_message(ignore_subscribe_messages=True, timeout=sleep_time) except BaseException as e: if self.exception_handler is None: raise @@ -1584,10 +1632,9 @@ class Pipeline(Redis): on a key of a different datatype. """ - UNWATCH_COMMANDS = {'DISCARD', 'EXEC', 'UNWATCH'} + UNWATCH_COMMANDS = {"DISCARD", "EXEC", "UNWATCH"} - def __init__(self, connection_pool, response_callbacks, transaction, - shard_hint): + def __init__(self, connection_pool, response_callbacks, transaction, shard_hint): self.connection_pool = connection_pool self.connection = None self.response_callbacks = response_callbacks @@ -1625,7 +1672,7 @@ def reset(self): try: # call this manually since our unwatch or # immediate_execute_command methods can call reset() - self.connection.send_command('UNWATCH') + self.connection.send_command("UNWATCH") self.connection.read_response() except ConnectionError: # disconnect will also remove any previous WATCHes @@ -1645,15 +1692,15 @@ def multi(self): are issued. End the transactional block with `execute`. """ if self.explicit_transaction: - raise RedisError('Cannot issue nested calls to MULTI') + raise RedisError("Cannot issue nested calls to MULTI") if self.command_stack: - raise RedisError('Commands without an initial WATCH have already ' - 'been issued') + raise RedisError( + "Commands without an initial WATCH have already " "been issued" + ) self.explicit_transaction = True def execute_command(self, *args, **kwargs): - if (self.watching or args[0] == 'WATCH') and \ - not self.explicit_transaction: + if (self.watching or args[0] == "WATCH") and not self.explicit_transaction: return self.immediate_execute_command(*args, **kwargs) return self.pipeline_execute_command(*args, **kwargs) @@ -1670,8 +1717,9 @@ def _disconnect_reset_raise(self, conn, error): # indicates the user should retry this transaction. if self.watching: self.reset() - raise WatchError("A ConnectionError occurred on while " - "watching one or more keys") + raise WatchError( + "A ConnectionError occurred on while " "watching one or more keys" + ) # if retry_on_timeout is not set, or the error is not # a TimeoutError, raise it if not (conn.retry_on_timeout and isinstance(error, TimeoutError)): @@ -1689,16 +1737,15 @@ def immediate_execute_command(self, *args, **options): conn = self.connection # if this is the first call, we need a connection if not conn: - conn = self.connection_pool.get_connection(command_name, - self.shard_hint) + conn = self.connection_pool.get_connection(command_name, self.shard_hint) self.connection = conn return conn.retry.call_with_retry( - lambda: self._send_command_parse_response(conn, - command_name, - *args, - **options), - lambda error: self._disconnect_reset_raise(conn, error)) + lambda: self._send_command_parse_response( + conn, command_name, *args, **options + ), + lambda error: self._disconnect_reset_raise(conn, error), + ) def pipeline_execute_command(self, *args, **options): """ @@ -1716,9 +1763,10 @@ def pipeline_execute_command(self, *args, **options): return self def _execute_transaction(self, connection, commands, raise_on_error): - cmds = chain([(('MULTI', ), {})], commands, [(('EXEC', ), {})]) - all_cmds = connection.pack_commands([args for args, options in cmds - if EMPTY_RESPONSE not in options]) + cmds = chain([(("MULTI",), {})], commands, [(("EXEC",), {})]) + all_cmds = connection.pack_commands( + [args for args, options in cmds if EMPTY_RESPONSE not in options] + ) connection.send_packed_command(all_cmds) errors = [] @@ -1727,7 +1775,7 @@ def _execute_transaction(self, connection, commands, raise_on_error): # so that we read all the additional command messages from # the socket try: - self.parse_response(connection, '_') + self.parse_response(connection, "_") except ResponseError as e: errors.append((0, e)) @@ -1737,14 +1785,14 @@ def _execute_transaction(self, connection, commands, raise_on_error): errors.append((i, command[1][EMPTY_RESPONSE])) else: try: - self.parse_response(connection, '_') + self.parse_response(connection, "_") except ResponseError as e: self.annotate_exception(e, i + 1, command[0]) errors.append((i, e)) # parse the EXEC. try: - response = self.parse_response(connection, '_') + response = self.parse_response(connection, "_") except ExecAbortError: if errors: raise errors[0][1] @@ -1762,8 +1810,9 @@ def _execute_transaction(self, connection, commands, raise_on_error): if len(response) != len(commands): self.connection.disconnect() - raise ResponseError("Wrong number of response items from " - "pipeline execution") + raise ResponseError( + "Wrong number of response items from " "pipeline execution" + ) # find any errors in the response and raise if necessary if raise_on_error: @@ -1788,8 +1837,7 @@ def _execute_pipeline(self, connection, commands, raise_on_error): response = [] for args, options in commands: try: - response.append( - self.parse_response(connection, args[0], **options)) + response.append(self.parse_response(connection, args[0], **options)) except ResponseError as e: response.append(e) @@ -1804,19 +1852,18 @@ def raise_first_error(self, commands, response): raise r def annotate_exception(self, exception, number, command): - cmd = ' '.join(map(safe_str, command)) + cmd = " ".join(map(safe_str, command)) msg = ( - f'Command # {number} ({cmd}) of pipeline ' - f'caused error: {exception.args[0]}' + f"Command # {number} ({cmd}) of pipeline " + f"caused error: {exception.args[0]}" ) exception.args = (msg,) + exception.args[1:] def parse_response(self, connection, command_name, **options): - result = Redis.parse_response( - self, connection, command_name, **options) + result = Redis.parse_response(self, connection, command_name, **options) if command_name in self.UNWATCH_COMMANDS: self.watching = False - elif command_name == 'WATCH': + elif command_name == "WATCH": self.watching = True return result @@ -1827,11 +1874,11 @@ def load_scripts(self): shas = [s.sha for s in scripts] # we can't use the normal script_* methods because they would just # get buffered in the pipeline. - exists = immediate('SCRIPT EXISTS', *shas) + exists = immediate("SCRIPT EXISTS", *shas) if not all(exists): for s, exist in zip(scripts, exists): if not exist: - s.sha = immediate('SCRIPT LOAD', s.script) + s.sha = immediate("SCRIPT LOAD", s.script) def _disconnect_raise_reset(self, conn, error): """ @@ -1844,8 +1891,9 @@ def _disconnect_raise_reset(self, conn, error): # since this connection has died. raise a WatchError, which # indicates the user should retry this transaction. if self.watching: - raise WatchError("A ConnectionError occurred on while " - "watching one or more keys") + raise WatchError( + "A ConnectionError occurred on while " "watching one or more keys" + ) # if retry_on_timeout is not set, or the error is not # a TimeoutError, raise it if not (conn.retry_on_timeout and isinstance(error, TimeoutError)): @@ -1866,8 +1914,7 @@ def execute(self, raise_on_error=True): conn = self.connection if not conn: - conn = self.connection_pool.get_connection('MULTI', - self.shard_hint) + conn = self.connection_pool.get_connection("MULTI", self.shard_hint) # assign to self.connection so reset() releases the connection # back to the pool after we're done self.connection = conn @@ -1875,7 +1922,8 @@ def execute(self, raise_on_error=True): try: return conn.retry.call_with_retry( lambda: execute(conn, stack, raise_on_error), - lambda error: self._disconnect_raise_reset(conn, error)) + lambda error: self._disconnect_raise_reset(conn, error), + ) finally: self.reset() @@ -1888,9 +1936,9 @@ def discard(self): def watch(self, *names): "Watches the values at keys ``names``" if self.explicit_transaction: - raise RedisError('Cannot issue a WATCH after a MULTI') - return self.execute_command('WATCH', *names) + raise RedisError("Cannot issue a WATCH after a MULTI") + return self.execute_command("WATCH", *names) def unwatch(self): "Unwatches all previously specified keys" - return self.watching and self.execute_command('UNWATCH') or True + return self.watching and self.execute_command("UNWATCH") or True diff --git a/redis/cluster.py b/redis/cluster.py index c1853aa876..57e8316ba2 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -2,18 +2,15 @@ import logging import random import socket -import time -import threading import sys - +import threading +import time from collections import OrderedDict -from redis.client import CaseInsensitiveDict, Redis, PubSub -from redis.commands import ( - ClusterCommands, - CommandsParser -) -from redis.connection import DefaultParser, ConnectionPool, Encoder, parse_url -from redis.crc import key_slot, REDIS_CLUSTER_HASH_SLOTS + +from redis.client import CaseInsensitiveDict, PubSub, Redis +from redis.commands import ClusterCommands, CommandsParser +from redis.connection import ConnectionPool, DefaultParser, Encoder, parse_url +from redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot from redis.exceptions import ( AskError, BusyLoadingError, @@ -34,15 +31,15 @@ dict_merge, list_keys_to_dict, merge_result, + safe_str, str_if_bytes, - safe_str ) log = logging.getLogger(__name__) def get_node_name(host, port): - return f'{host}:{port}' + return f"{host}:{port}" def get_connection(redis_node, *args, **options): @@ -67,15 +64,12 @@ def parse_pubsub_numsub(command, res, **options): except KeyError: numsub_d[channel] = numsubbed - ret_numsub = [ - (channel, numsub) - for channel, numsub in numsub_d.items() - ] + ret_numsub = [(channel, numsub) for channel, numsub in numsub_d.items()] return ret_numsub def parse_cluster_slots(resp, **options): - current_host = options.get('current_host', '') + current_host = options.get("current_host", "") def fix_server(*args): return str_if_bytes(args[0]) or current_host, args[1] @@ -85,8 +79,8 @@ def fix_server(*args): start, end, primary = slot[:3] replicas = slot[3:] slots[start, end] = { - 'primary': fix_server(*primary), - 'replicas': [fix_server(*replica) for replica in replicas], + "primary": fix_server(*primary), + "replicas": [fix_server(*replica) for replica in replicas], } return slots @@ -132,47 +126,49 @@ def fix_server(*args): # Not complete, but covers the major ones # https://redis.io/commands -READ_COMMANDS = frozenset([ - "BITCOUNT", - "BITPOS", - "EXISTS", - "GEODIST", - "GEOHASH", - "GEOPOS", - "GEORADIUS", - "GEORADIUSBYMEMBER", - "GET", - "GETBIT", - "GETRANGE", - "HEXISTS", - "HGET", - "HGETALL", - "HKEYS", - "HLEN", - "HMGET", - "HSTRLEN", - "HVALS", - "KEYS", - "LINDEX", - "LLEN", - "LRANGE", - "MGET", - "PTTL", - "RANDOMKEY", - "SCARD", - "SDIFF", - "SINTER", - "SISMEMBER", - "SMEMBERS", - "SRANDMEMBER", - "STRLEN", - "SUNION", - "TTL", - "ZCARD", - "ZCOUNT", - "ZRANGE", - "ZSCORE", -]) +READ_COMMANDS = frozenset( + [ + "BITCOUNT", + "BITPOS", + "EXISTS", + "GEODIST", + "GEOHASH", + "GEOPOS", + "GEORADIUS", + "GEORADIUSBYMEMBER", + "GET", + "GETBIT", + "GETRANGE", + "HEXISTS", + "HGET", + "HGETALL", + "HKEYS", + "HLEN", + "HMGET", + "HSTRLEN", + "HVALS", + "KEYS", + "LINDEX", + "LLEN", + "LRANGE", + "MGET", + "PTTL", + "RANDOMKEY", + "SCARD", + "SDIFF", + "SINTER", + "SISMEMBER", + "SMEMBERS", + "SRANDMEMBER", + "STRLEN", + "SUNION", + "TTL", + "ZCARD", + "ZCOUNT", + "ZRANGE", + "ZSCORE", + ] +) def cleanup_kwargs(**kwargs): @@ -190,14 +186,16 @@ def cleanup_kwargs(**kwargs): class ClusterParser(DefaultParser): EXCEPTION_CLASSES = dict_merge( - DefaultParser.EXCEPTION_CLASSES, { - 'ASK': AskError, - 'TRYAGAIN': TryAgainError, - 'MOVED': MovedError, - 'CLUSTERDOWN': ClusterDownError, - 'CROSSSLOT': ClusterCrossSlotError, - 'MASTERDOWN': MasterDownError, - }) + DefaultParser.EXCEPTION_CLASSES, + { + "ASK": AskError, + "TRYAGAIN": TryAgainError, + "MOVED": MovedError, + "CLUSTERDOWN": ClusterDownError, + "CROSSSLOT": ClusterCrossSlotError, + "MASTERDOWN": MasterDownError, + }, + ) class RedisCluster(ClusterCommands): @@ -209,13 +207,7 @@ class RedisCluster(ClusterCommands): RANDOM = "random" DEFAULT_NODE = "default-node" - NODE_FLAGS = { - PRIMARIES, - REPLICAS, - ALL_NODES, - RANDOM, - DEFAULT_NODE - } + NODE_FLAGS = {PRIMARIES, REPLICAS, ALL_NODES, RANDOM, DEFAULT_NODE} COMMAND_FLAGS = dict_merge( list_keys_to_dict( @@ -292,119 +284,138 @@ class RedisCluster(ClusterCommands): ) CLUSTER_COMMANDS_RESPONSE_CALLBACKS = { - 'CLUSTER ADDSLOTS': bool, - 'CLUSTER COUNT-FAILURE-REPORTS': int, - 'CLUSTER COUNTKEYSINSLOT': int, - 'CLUSTER DELSLOTS': bool, - 'CLUSTER FAILOVER': bool, - 'CLUSTER FORGET': bool, - 'CLUSTER GETKEYSINSLOT': list, - 'CLUSTER KEYSLOT': int, - 'CLUSTER MEET': bool, - 'CLUSTER REPLICATE': bool, - 'CLUSTER RESET': bool, - 'CLUSTER SAVECONFIG': bool, - 'CLUSTER SET-CONFIG-EPOCH': bool, - 'CLUSTER SETSLOT': bool, - 'CLUSTER SLOTS': parse_cluster_slots, - 'ASKING': bool, - 'READONLY': bool, - 'READWRITE': bool, + "CLUSTER ADDSLOTS": bool, + "CLUSTER COUNT-FAILURE-REPORTS": int, + "CLUSTER COUNTKEYSINSLOT": int, + "CLUSTER DELSLOTS": bool, + "CLUSTER FAILOVER": bool, + "CLUSTER FORGET": bool, + "CLUSTER GETKEYSINSLOT": list, + "CLUSTER KEYSLOT": int, + "CLUSTER MEET": bool, + "CLUSTER REPLICATE": bool, + "CLUSTER RESET": bool, + "CLUSTER SAVECONFIG": bool, + "CLUSTER SET-CONFIG-EPOCH": bool, + "CLUSTER SETSLOT": bool, + "CLUSTER SLOTS": parse_cluster_slots, + "ASKING": bool, + "READONLY": bool, + "READWRITE": bool, } RESULT_CALLBACKS = dict_merge( - list_keys_to_dict([ - "PUBSUB NUMSUB", - ], parse_pubsub_numsub), - list_keys_to_dict([ - "PUBSUB NUMPAT", - ], lambda command, res: sum(list(res.values()))), - list_keys_to_dict([ - "KEYS", - "PUBSUB CHANNELS", - ], merge_result), - list_keys_to_dict([ - "PING", - "CONFIG SET", - "CONFIG REWRITE", - "CONFIG RESETSTAT", - "CLIENT SETNAME", - "BGSAVE", - "SLOWLOG RESET", - "SAVE", - "MEMORY PURGE", - "CLIENT PAUSE", - "CLIENT UNPAUSE", - ], lambda command, res: all(res.values()) if isinstance(res, dict) - else res), - list_keys_to_dict([ - "DBSIZE", - "WAIT", - ], lambda command, res: sum(res.values()) if isinstance(res, dict) - else res), - list_keys_to_dict([ - "CLIENT UNBLOCK", - ], lambda command, res: 1 if sum(res.values()) > 0 else 0), - list_keys_to_dict([ - "SCAN", - ], parse_scan_result) + list_keys_to_dict( + [ + "PUBSUB NUMSUB", + ], + parse_pubsub_numsub, + ), + list_keys_to_dict( + [ + "PUBSUB NUMPAT", + ], + lambda command, res: sum(list(res.values())), + ), + list_keys_to_dict( + [ + "KEYS", + "PUBSUB CHANNELS", + ], + merge_result, + ), + list_keys_to_dict( + [ + "PING", + "CONFIG SET", + "CONFIG REWRITE", + "CONFIG RESETSTAT", + "CLIENT SETNAME", + "BGSAVE", + "SLOWLOG RESET", + "SAVE", + "MEMORY PURGE", + "CLIENT PAUSE", + "CLIENT UNPAUSE", + ], + lambda command, res: all(res.values()) if isinstance(res, dict) else res, + ), + list_keys_to_dict( + [ + "DBSIZE", + "WAIT", + ], + lambda command, res: sum(res.values()) if isinstance(res, dict) else res, + ), + list_keys_to_dict( + [ + "CLIENT UNBLOCK", + ], + lambda command, res: 1 if sum(res.values()) > 0 else 0, + ), + list_keys_to_dict( + [ + "SCAN", + ], + parse_scan_result, + ), ) def __init__( - self, - host=None, - port=6379, - startup_nodes=None, - cluster_error_retry_attempts=3, - require_full_coverage=True, - skip_full_coverage_check=False, - reinitialize_steps=10, - read_from_replicas=False, - url=None, - retry_on_timeout=False, - retry=None, - **kwargs + self, + host=None, + port=6379, + startup_nodes=None, + cluster_error_retry_attempts=3, + require_full_coverage=True, + skip_full_coverage_check=False, + reinitialize_steps=10, + read_from_replicas=False, + url=None, + retry_on_timeout=False, + retry=None, + **kwargs, ): """ - Initialize a new RedisCluster client. - - :startup_nodes: 'list[ClusterNode]' - List of nodes from which initial bootstrapping can be done - :host: 'str' - Can be used to point to a startup node - :port: 'int' - Can be used to point to a startup node - :require_full_coverage: 'bool' - If set to True, as it is by default, all slots must be covered. - If set to False and not all slots are covered, the instance - creation will succeed only if 'cluster-require-full-coverage' - configuration is set to 'no' in all of the cluster's nodes. - Otherwise, RedisClusterException will be thrown. - :skip_full_coverage_check: 'bool' - If require_full_coverage is set to False, a check of - cluster-require-full-coverage config will be executed against all - nodes. Set skip_full_coverage_check to True to skip this check. - Useful for clusters without the CONFIG command (like ElastiCache) - :read_from_replicas: 'bool' - Enable read from replicas in READONLY mode. You can read possibly - stale data. - When set to true, read commands will be assigned between the - primary and its replications in a Round-Robin manner. - :cluster_error_retry_attempts: 'int' - Retry command execution attempts when encountering ClusterDownError - or ConnectionError - :retry_on_timeout: 'bool' - To specify a retry policy, first set `retry_on_timeout` to `True` - then set `retry` to a valid `Retry` object - :retry: 'Retry' - a `Retry` object - :**kwargs: - Extra arguments that will be sent into Redis instance when created - (See Official redis-py doc for supported kwargs - [https://github.com/andymccurdy/redis-py/blob/master/redis/client.py]) - Some kwargs are not supported and will raise a - RedisClusterException: - - db (Redis do not support database SELECT in cluster mode) + Initialize a new RedisCluster client. + + :startup_nodes: 'list[ClusterNode]' + List of nodes from which initial bootstrapping can be done + :host: 'str' + Can be used to point to a startup node + :port: 'int' + Can be used to point to a startup node + :require_full_coverage: 'bool' + If set to True, as it is by default, all slots must be covered. + If set to False and not all slots are covered, the instance + creation will succeed only if 'cluster-require-full-coverage' + configuration is set to 'no' in all of the cluster's nodes. + Otherwise, RedisClusterException will be thrown. + :skip_full_coverage_check: 'bool' + If require_full_coverage is set to False, a check of + cluster-require-full-coverage config will be executed against all + nodes. Set skip_full_coverage_check to True to skip this check. + Useful for clusters without the CONFIG command (like ElastiCache) + :read_from_replicas: 'bool' + Enable read from replicas in READONLY mode. You can read possibly + stale data. + When set to true, read commands will be assigned between the + primary and its replications in a Round-Robin manner. + :cluster_error_retry_attempts: 'int' + Retry command execution attempts when encountering ClusterDownError + or ConnectionError + :retry_on_timeout: 'bool' + To specify a retry policy, first set `retry_on_timeout` to `True` + then set `retry` to a valid `Retry` object + :retry: 'Retry' + a `Retry` object + :**kwargs: + Extra arguments that will be sent into Redis instance when created + (See Official redis-py doc for supported kwargs + [https://github.com/andymccurdy/redis-py/blob/master/redis/client.py]) + Some kwargs are not supported and will raise a + RedisClusterException: + - db (Redis do not support database SELECT in cluster mode) """ log.info("Creating a new instance of RedisCluster client") @@ -418,8 +429,7 @@ def __init__( ) if retry_on_timeout: - kwargs.update({'retry_on_timeout': retry_on_timeout, - 'retry': retry}) + kwargs.update({"retry_on_timeout": retry_on_timeout, "retry": retry}) # Get the startup node/s from_url = False @@ -429,15 +439,16 @@ def __init__( if "path" in url_options: raise RedisClusterException( "RedisCluster does not currently support Unix Domain " - "Socket connections") + "Socket connections" + ) if "db" in url_options and url_options["db"] != 0: # Argument 'db' is not possible to use in cluster mode raise RedisClusterException( "A ``db`` querystring option can only be 0 in cluster mode" ) kwargs.update(url_options) - host = kwargs.get('host') - port = kwargs.get('port', port) + host = kwargs.get("host") + port = kwargs.get("port", port) startup_nodes.append(ClusterNode(host, port)) elif host is not None and port is not None: startup_nodes.append(ClusterNode(host, port)) @@ -450,7 +461,8 @@ def __init__( " RedisCluster(host='localhost', port=6379)\n" "2. list of startup nodes, for example:\n" " RedisCluster(startup_nodes=[ClusterNode('localhost', 6379)," - " ClusterNode('localhost', 6378)])") + " ClusterNode('localhost', 6378)])" + ) log.debug(f"startup_nodes : {startup_nodes}") # Update the connection arguments # Whenever a new connection is established, RedisCluster's on_connect @@ -482,9 +494,9 @@ def __init__( ) self.cluster_response_callbacks = CaseInsensitiveDict( - self.__class__.CLUSTER_COMMANDS_RESPONSE_CALLBACKS) - self.result_callbacks = CaseInsensitiveDict( - self.__class__.RESULT_CALLBACKS) + self.__class__.CLUSTER_COMMANDS_RESPONSE_CALLBACKS + ) + self.result_callbacks = CaseInsensitiveDict(self.__class__.RESULT_CALLBACKS) self.commands_parser = CommandsParser(self) self._lock = threading.Lock() @@ -563,9 +575,9 @@ def on_connect(self, connection): # to a failover, we should establish a READONLY connection # regardless of the server type. If this is a primary connection, # READONLY would not affect executing write commands. - connection.send_command('READONLY') - if str_if_bytes(connection.read_response()) != 'OK': - raise ConnectionError('READONLY command failed') + connection.send_command("READONLY") + if str_if_bytes(connection.read_response()) != "OK": + raise ConnectionError("READONLY command failed") if self.user_on_connect_func is not None: self.user_on_connect_func(connection) @@ -601,9 +613,7 @@ def get_node_from_key(self, key, replica=False): slot = self.keyslot(key) slot_cache = self.nodes_manager.slots_cache.get(slot) if slot_cache is None or len(slot_cache) == 0: - raise SlotNotCoveredError( - f'Slot "{slot}" is not covered by the cluster.' - ) + raise SlotNotCoveredError(f'Slot "{slot}" is not covered by the cluster.') if replica and len(self.nodes_manager.slots_cache[slot]) < 2: return None elif replica: @@ -627,8 +637,10 @@ def set_default_node(self, node): :return True if the default node was set, else False """ if node is None or self.get_node(node_name=node.name) is None: - log.info("The requested node does not exist in the cluster, so " - "the default node was not changed.") + log.info( + "The requested node does not exist in the cluster, so " + "the default node was not changed." + ) return False self.nodes_manager.default_node = node log.info(f"Changed the default cluster node to {node}") @@ -651,12 +663,10 @@ def pipeline(self, transaction=None, shard_hint=None): when calling execute() will only return the result stack. """ if shard_hint: - raise RedisClusterException( - "shard_hint is deprecated in cluster mode") + raise RedisClusterException("shard_hint is deprecated in cluster mode") if transaction: - raise RedisClusterException( - "transaction is deprecated in cluster mode") + raise RedisClusterException("transaction is deprecated in cluster mode") return ClusterPipeline( nodes_manager=self.nodes_manager, @@ -665,7 +675,7 @@ def pipeline(self, transaction=None, shard_hint=None): cluster_response_callbacks=self.cluster_response_callbacks, cluster_error_retry_attempts=self.cluster_error_retry_attempts, read_from_replicas=self.read_from_replicas, - reinitialize_steps=self.reinitialize_steps + reinitialize_steps=self.reinitialize_steps, ) def _determine_nodes(self, *args, **kwargs): @@ -698,7 +708,8 @@ def _determine_nodes(self, *args, **kwargs): # get the node that holds the key's slot slot = self.determine_slot(*args) node = self.nodes_manager.get_node_from_slot( - slot, self.read_from_replicas and command in READ_COMMANDS) + slot, self.read_from_replicas and command in READ_COMMANDS + ) log.debug(f"Target for {args}: slot {slot}") return [node] @@ -760,8 +771,7 @@ def reinitialize_caches(self): self.nodes_manager.initialize() def _is_nodes_flag(self, target_nodes): - return isinstance(target_nodes, str) \ - and target_nodes in self.node_flags + return isinstance(target_nodes, str) and target_nodes in self.node_flags def _parse_target_nodes(self, target_nodes): if isinstance(target_nodes, list): @@ -812,8 +822,9 @@ def execute_command(self, *args, **kwargs): # the command execution since the nodes may not be valid anymore # after the tables were reinitialized. So in case of passed target # nodes, retry_attempts will be set to 1. - retry_attempts = 1 if target_nodes_specified else \ - self.cluster_error_retry_attempts + retry_attempts = ( + 1 if target_nodes_specified else self.cluster_error_retry_attempts + ) exception = None for _ in range(0, retry_attempts): try: @@ -821,13 +832,14 @@ def execute_command(self, *args, **kwargs): if not target_nodes_specified: # Determine the nodes to execute the command on target_nodes = self._determine_nodes( - *args, **kwargs, nodes_flag=target_nodes) + *args, **kwargs, nodes_flag=target_nodes + ) if not target_nodes: raise RedisClusterException( - f"No targets were found to execute {args} command on") + f"No targets were found to execute {args} command on" + ) for node in target_nodes: - res[node.name] = self._execute_command( - node, *args, **kwargs) + res[node.name] = self._execute_command(node, *args, **kwargs) # Return the processed result return self._process_result(args[0], res, **kwargs) except (ClusterDownError, ConnectionError) as e: @@ -862,9 +874,9 @@ def _execute_command(self, target_node, *args, **kwargs): # MOVED occurred and the slots cache was updated, # refresh the target node slot = self.determine_slot(*args) - target_node = self.nodes_manager. \ - get_node_from_slot(slot, self.read_from_replicas and - command in READ_COMMANDS) + target_node = self.nodes_manager.get_node_from_slot( + slot, self.read_from_replicas and command in READ_COMMANDS + ) moved = False log.debug( @@ -879,11 +891,11 @@ def _execute_command(self, target_node, *args, **kwargs): asking = False connection.send_command(*args) - response = redis_node.parse_response(connection, command, - **kwargs) + response = redis_node.parse_response(connection, command, **kwargs) if command in self.cluster_response_callbacks: response = self.cluster_response_callbacks[command]( - response, **kwargs) + response, **kwargs + ) return response except (RedisClusterException, BusyLoadingError): @@ -997,7 +1009,7 @@ def _process_result(self, command, res, **kwargs): class ClusterNode: def __init__(self, host, port, server_type=None, redis_connection=None): - if host == 'localhost': + if host == "localhost": host = socket.gethostbyname(host) self.host = host @@ -1008,11 +1020,11 @@ def __init__(self, host, port, server_type=None, redis_connection=None): def __repr__(self): return ( - f'[host={self.host},' - f'port={self.port},' - f'name={self.name},' - f'server_type={self.server_type},' - f'redis_connection={self.redis_connection}]' + f"[host={self.host}," + f"port={self.port}," + f"name={self.name}," + f"server_type={self.server_type}," + f"redis_connection={self.redis_connection}]" ) def __eq__(self, obj): @@ -1029,8 +1041,7 @@ def __init__(self, start_index=0): self.start_index = start_index def get_server_index(self, primary, list_size): - server_index = self.primary_to_idx.setdefault(primary, - self.start_index) + server_index = self.primary_to_idx.setdefault(primary, self.start_index) # Update the index self.primary_to_idx[primary] = (server_index + 1) % list_size return server_index @@ -1040,9 +1051,15 @@ def reset(self): class NodesManager: - def __init__(self, startup_nodes, from_url=False, - require_full_coverage=True, skip_full_coverage_check=False, - lock=None, **kwargs): + def __init__( + self, + startup_nodes, + from_url=False, + require_full_coverage=True, + skip_full_coverage_check=False, + lock=None, + **kwargs, + ): self.nodes_cache = {} self.slots_cache = {} self.startup_nodes = {} @@ -1122,8 +1139,7 @@ def _update_moved_slots(self): # Reset moved_exception self._moved_exception = None - def get_node_from_slot(self, slot, read_from_replicas=False, - server_type=None): + def get_node_from_slot(self, slot, read_from_replicas=False, server_type=None): """ Gets a node that servers this hash slot """ @@ -1132,8 +1148,7 @@ def get_node_from_slot(self, slot, read_from_replicas=False, if self._moved_exception: self._update_moved_slots() - if self.slots_cache.get(slot) is None or \ - len(self.slots_cache[slot]) == 0: + if self.slots_cache.get(slot) is None or len(self.slots_cache[slot]) == 0: raise SlotNotCoveredError( f'Slot "{slot}" not covered by the cluster. ' f'"require_full_coverage={self._require_full_coverage}"' @@ -1143,19 +1158,19 @@ def get_node_from_slot(self, slot, read_from_replicas=False, # get the server index in a Round-Robin manner primary_name = self.slots_cache[slot][0].name node_idx = self.read_load_balancer.get_server_index( - primary_name, len(self.slots_cache[slot])) + primary_name, len(self.slots_cache[slot]) + ) elif ( - server_type is None - or server_type == PRIMARY - or len(self.slots_cache[slot]) == 1 + server_type is None + or server_type == PRIMARY + or len(self.slots_cache[slot]) == 1 ): # return a primary node_idx = 0 else: # return a replica # randomly choose one of the replicas - node_idx = random.randint( - 1, len(self.slots_cache[slot]) - 1) + node_idx = random.randint(1, len(self.slots_cache[slot]) - 1) return self.slots_cache[slot][node_idx] @@ -1187,20 +1202,22 @@ def cluster_require_full_coverage(self, cluster_nodes): def node_require_full_coverage(node): try: - return ("yes" in node.redis_connection.config_get( - "cluster-require-full-coverage").values() + return ( + "yes" + in node.redis_connection.config_get( + "cluster-require-full-coverage" + ).values() ) except ConnectionError: return False except Exception as e: raise RedisClusterException( 'ERROR sending "config get cluster-require-full-coverage"' - f' command to redis server: {node.name}, {e}' + f" command to redis server: {node.name}, {e}" ) # at least one node should have cluster-require-full-coverage yes - return any(node_require_full_coverage(node) - for node in cluster_nodes.values()) + return any(node_require_full_coverage(node) for node in cluster_nodes.values()) def check_slots_coverage(self, slots_cache): # Validate if all slots are covered or if we should try next @@ -1229,11 +1246,7 @@ def create_redis_node(self, host, port, **kwargs): kwargs.update({"port": port}) r = Redis(connection_pool=ConnectionPool(**kwargs)) else: - r = Redis( - host=host, - port=port, - **kwargs - ) + r = Redis(host=host, port=port, **kwargs) return r def initialize(self): @@ -1257,22 +1270,23 @@ def initialize(self): # Create a new Redis connection and let Redis decode the # responses so we won't need to handle that copy_kwargs = copy.deepcopy(kwargs) - copy_kwargs.update({"decode_responses": True, - "encoding": "utf-8"}) + copy_kwargs.update({"decode_responses": True, "encoding": "utf-8"}) r = self.create_redis_node( - startup_node.host, startup_node.port, **copy_kwargs) + startup_node.host, startup_node.port, **copy_kwargs + ) self.startup_nodes[startup_node.name].redis_connection = r cluster_slots = r.execute_command("CLUSTER SLOTS") startup_nodes_reachable = True except (ConnectionError, TimeoutError) as e: msg = e.__str__ - log.exception('An exception occurred while trying to' - ' initialize the cluster using the seed node' - f' {startup_node.name}:\n{msg}') + log.exception( + "An exception occurred while trying to" + " initialize the cluster using the seed node" + f" {startup_node.name}:\n{msg}" + ) continue except ResponseError as e: - log.exception( - 'ReseponseError sending "cluster slots" to redis server') + log.exception('ReseponseError sending "cluster slots" to redis server') # Isn't a cluster connection, so it won't parse these # exceptions automatically @@ -1282,13 +1296,13 @@ def initialize(self): else: raise RedisClusterException( 'ERROR sending "cluster slots" command to redis ' - f'server: {startup_node}. error: {message}' + f"server: {startup_node}. error: {message}" ) except Exception as e: message = e.__str__() raise RedisClusterException( 'ERROR sending "cluster slots" command to redis ' - f'server: {startup_node}. error: {message}' + f"server: {startup_node}. error: {message}" ) # CLUSTER SLOTS command results in the following output: @@ -1298,9 +1312,11 @@ def initialize(self): # primary node of the first slot section. # If there's only one server in the cluster, its ``host`` is '' # Fix it to the host in startup_nodes - if (len(cluster_slots) == 1 - and len(cluster_slots[0][2][0]) == 0 - and len(self.startup_nodes) == 1): + if ( + len(cluster_slots) == 1 + and len(cluster_slots[0][2][0]) == 0 + and len(self.startup_nodes) == 1 + ): cluster_slots[0][2][0] = startup_node.host for slot in cluster_slots: @@ -1327,10 +1343,10 @@ def initialize(self): port = replica_node[1] target_replica_node = tmp_nodes_cache.get( - get_node_name(host, port)) + get_node_name(host, port) + ) if target_replica_node is None: - target_replica_node = ClusterNode( - host, port, REPLICA) + target_replica_node = ClusterNode(host, port, REPLICA) tmp_slots[i].append(target_replica_node) # add this node to the nodes cache tmp_nodes_cache[ @@ -1342,12 +1358,12 @@ def initialize(self): tmp_slot = tmp_slots[i][0] if tmp_slot.name != target_node.name: disagreements.append( - f'{tmp_slot.name} vs {target_node.name} on slot: {i}' + f"{tmp_slot.name} vs {target_node.name} on slot: {i}" ) if len(disagreements) > 5: raise RedisClusterException( - f'startup_nodes could not agree on a valid ' + f"startup_nodes could not agree on a valid " f'slots cache: {", ".join(disagreements)}' ) @@ -1366,8 +1382,8 @@ def initialize(self): # Despite the requirement that the slots be covered, there # isn't a full coverage raise RedisClusterException( - f'All slots are not covered after query all startup_nodes. ' - f'{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} covered...' + f"All slots are not covered after query all startup_nodes. " + f"{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} covered..." ) elif not fully_covered and not self._require_full_coverage: # The user set require_full_coverage to False. @@ -1376,15 +1392,17 @@ def initialize(self): # continue with partial coverage. # see Redis Cluster configuration parameters in # https://redis.io/topics/cluster-tutorial - if not self._skip_full_coverage_check and \ - self.cluster_require_full_coverage(tmp_nodes_cache): + if ( + not self._skip_full_coverage_check + and self.cluster_require_full_coverage(tmp_nodes_cache) + ): raise RedisClusterException( - 'Not all slots are covered but the cluster\'s ' - 'configuration requires full coverage. Set ' - 'cluster-require-full-coverage configuration to no on ' - 'all of the cluster nodes if you wish the cluster to ' - 'be able to serve without being fully covered.' - f'{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} covered...' + "Not all slots are covered but the cluster's " + "configuration requires full coverage. Set " + "cluster-require-full-coverage configuration to no on " + "all of the cluster nodes if you wish the cluster to " + "be able to serve without being fully covered." + f"{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} covered..." ) # Set the tmp variables to the real variables @@ -1418,8 +1436,7 @@ class ClusterPubSub(PubSub): https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html """ - def __init__(self, redis_cluster, node=None, host=None, port=None, - **kwargs): + def __init__(self, redis_cluster, node=None, host=None, port=None, **kwargs): """ When a pubsub instance is created without specifying a node, a single node will be transparently chosen for the pubsub connection on the @@ -1436,11 +1453,15 @@ def __init__(self, redis_cluster, node=None, host=None, port=None, log.info("Creating new instance of ClusterPubSub") self.node = None self.set_pubsub_node(redis_cluster, node, host, port) - connection_pool = None if self.node is None else \ - redis_cluster.get_redis_connection(self.node).connection_pool + connection_pool = ( + None + if self.node is None + else redis_cluster.get_redis_connection(self.node).connection_pool + ) self.cluster = redis_cluster - super().__init__(**kwargs, connection_pool=connection_pool, - encoder=redis_cluster.encoder) + super().__init__( + **kwargs, connection_pool=connection_pool, encoder=redis_cluster.encoder + ) def set_pubsub_node(self, cluster, node=None, host=None, port=None): """ @@ -1468,8 +1489,7 @@ def set_pubsub_node(self, cluster, node=None, host=None, port=None): pubsub_node = node elif any([host, port]) is True: # only 'host' or 'port' passed - raise DataError('Passing a host requires passing a port, ' - 'and vice versa') + raise DataError("Passing a host requires passing a port, " "and vice versa") else: # nothing passed by the user. set node to None pubsub_node = None @@ -1489,7 +1509,8 @@ def _raise_on_invalid_node(self, redis_cluster, node, host, port): """ if node is None or redis_cluster.get_node(node_name=node.name) is None: raise RedisClusterException( - f"Node {host}:{port} doesn't exist in the cluster") + f"Node {host}:{port} doesn't exist in the cluster" + ) def execute_command(self, *args, **kwargs): """ @@ -1508,9 +1529,9 @@ def execute_command(self, *args, **kwargs): # this slot channel = args[1] slot = self.cluster.keyslot(channel) - node = self.cluster.nodes_manager. \ - get_node_from_slot(slot, self.cluster. - read_from_replicas) + node = self.cluster.nodes_manager.get_node_from_slot( + slot, self.cluster.read_from_replicas + ) else: # Get a random node node = self.cluster.get_random_node() @@ -1518,8 +1539,7 @@ def execute_command(self, *args, **kwargs): redis_connection = self.cluster.get_redis_connection(node) self.connection_pool = redis_connection.connection_pool self.connection = self.connection_pool.get_connection( - 'pubsub', - self.shard_hint + "pubsub", self.shard_hint ) # register a callback that re-subscribes to any channels we # were listening to when we were disconnected @@ -1535,8 +1555,13 @@ def get_redis_connection(self): return self.node.redis_connection -ERRORS_ALLOW_RETRY = (ConnectionError, TimeoutError, - MovedError, AskError, TryAgainError) +ERRORS_ALLOW_RETRY = ( + ConnectionError, + TimeoutError, + MovedError, + AskError, + TryAgainError, +) class ClusterPipeline(RedisCluster): @@ -1545,18 +1570,25 @@ class ClusterPipeline(RedisCluster): in cluster mode """ - def __init__(self, nodes_manager, result_callbacks=None, - cluster_response_callbacks=None, startup_nodes=None, - read_from_replicas=False, cluster_error_retry_attempts=3, - reinitialize_steps=10, **kwargs): - """ - """ + def __init__( + self, + nodes_manager, + result_callbacks=None, + cluster_response_callbacks=None, + startup_nodes=None, + read_from_replicas=False, + cluster_error_retry_attempts=3, + reinitialize_steps=10, + **kwargs, + ): + """ """ log.info("Creating new instance of ClusterPipeline") self.command_stack = [] self.nodes_manager = nodes_manager self.refresh_table_asap = False - self.result_callbacks = (result_callbacks or - self.__class__.RESULT_CALLBACKS.copy()) + self.result_callbacks = ( + result_callbacks or self.__class__.RESULT_CALLBACKS.copy() + ) self.startup_nodes = startup_nodes if startup_nodes else [] self.read_from_replicas = read_from_replicas self.command_flags = self.__class__.COMMAND_FLAGS.copy() @@ -1576,18 +1608,15 @@ def __init__(self, nodes_manager, result_callbacks=None, self.commands_parser = CommandsParser(super()) def __repr__(self): - """ - """ + """ """ return f"{type(self).__name__}" def __enter__(self): - """ - """ + """ """ return self def __exit__(self, exc_type, exc_value, traceback): - """ - """ + """ """ self.reset() def __del__(self): @@ -1597,8 +1626,7 @@ def __del__(self): pass def __len__(self): - """ - """ + """ """ return len(self.command_stack) def __nonzero__(self): @@ -1620,7 +1648,8 @@ def pipeline_execute_command(self, *args, **options): Appends the executed command to the pipeline's command stack """ self.command_stack.append( - PipelineCommand(args, options, len(self.command_stack))) + PipelineCommand(args, options, len(self.command_stack)) + ) return self def raise_first_error(self, stack): @@ -1637,10 +1666,10 @@ def annotate_exception(self, exception, number, command): """ Provides extra context to the exception prior to it being handled """ - cmd = ' '.join(map(safe_str, command)) + cmd = " ".join(map(safe_str, command)) msg = ( - f'Command # {number} ({cmd}) of pipeline ' - f'caused error: {exception.args[0]}' + f"Command # {number} ({cmd}) of pipeline " + f"caused error: {exception.args[0]}" ) exception.args = (msg,) + exception.args[1:] @@ -1686,8 +1715,9 @@ def reset(self): # self.connection_pool.release(self.connection) # self.connection = None - def send_cluster_commands(self, stack, - raise_on_error=True, allow_redirections=True): + def send_cluster_commands( + self, stack, raise_on_error=True, allow_redirections=True + ): """ Wrapper for CLUSTERDOWN error handling. @@ -1720,12 +1750,11 @@ def send_cluster_commands(self, stack, # If it fails the configured number of times then raise # exception back to caller of this method - raise ClusterDownError( - "CLUSTERDOWN error. Unable to rebuild the cluster") + raise ClusterDownError("CLUSTERDOWN error. Unable to rebuild the cluster") - def _send_cluster_commands(self, stack, - raise_on_error=True, - allow_redirections=True): + def _send_cluster_commands( + self, stack, raise_on_error=True, allow_redirections=True + ): """ Send a bunch of cluster commands to the redis cluster. @@ -1751,7 +1780,8 @@ def _send_cluster_commands(self, stack, # command should route to. slot = self.determine_slot(*c.args) node = self.nodes_manager.get_node_from_slot( - slot, self.read_from_replicas and c.args[0] in READ_COMMANDS) + slot, self.read_from_replicas and c.args[0] in READ_COMMANDS + ) # now that we know the name of the node # ( it's just a string in the form of host:port ) @@ -1760,9 +1790,9 @@ def _send_cluster_commands(self, stack, if node_name not in nodes: redis_node = self.get_redis_connection(node) connection = get_connection(redis_node, c.args) - nodes[node_name] = NodeCommands(redis_node.parse_response, - redis_node.connection_pool, - connection) + nodes[node_name] = NodeCommands( + redis_node.parse_response, redis_node.connection_pool, connection + ) nodes[node_name].append(c) @@ -1808,9 +1838,10 @@ def _send_cluster_commands(self, stack, # if we have more commands to attempt, we've run into problems. # collect all the commands we are allowed to retry. # (MOVED, ASK, or connection errors or timeout errors) - attempt = sorted((c for c in attempt - if isinstance(c.result, ERRORS_ALLOW_RETRY)), - key=lambda x: x.position) + attempt = sorted( + (c for c in attempt if isinstance(c.result, ERRORS_ALLOW_RETRY)), + key=lambda x: x.position, + ) if attempt and allow_redirections: # RETRY MAGIC HAPPENS HERE! # send these remaing comamnds one at a time using `execute_command` @@ -1831,10 +1862,10 @@ def _send_cluster_commands(self, stack, # flag to rebuild the slots table from scratch. # So MOVED errors should correct themselves fairly quickly. log.exception( - f'An exception occurred during pipeline execution. ' - f'args: {attempt[-1].args}, ' - f'error: {type(attempt[-1].result).__name__} ' - f'{str(attempt[-1].result)}' + f"An exception occurred during pipeline execution. " + f"args: {attempt[-1].args}, " + f"error: {type(attempt[-1].result).__name__} " + f"{str(attempt[-1].result)}" ) self.reinitialize_counter += 1 if self._should_reinitialized(): @@ -1857,55 +1888,47 @@ def _send_cluster_commands(self, stack, return response def _fail_on_redirect(self, allow_redirections): - """ - """ + """ """ if not allow_redirections: raise RedisClusterException( - "ASK & MOVED redirection not allowed in this pipeline") + "ASK & MOVED redirection not allowed in this pipeline" + ) def eval(self): - """ - """ + """ """ raise RedisClusterException("method eval() is not implemented") def multi(self): - """ - """ + """ """ raise RedisClusterException("method multi() is not implemented") def immediate_execute_command(self, *args, **options): - """ - """ + """ """ raise RedisClusterException( - "method immediate_execute_command() is not implemented") + "method immediate_execute_command() is not implemented" + ) def _execute_transaction(self, *args, **kwargs): - """ - """ - raise RedisClusterException( - "method _execute_transaction() is not implemented") + """ """ + raise RedisClusterException("method _execute_transaction() is not implemented") def load_scripts(self): - """ - """ - raise RedisClusterException( - "method load_scripts() is not implemented") + """ """ + raise RedisClusterException("method load_scripts() is not implemented") def watch(self, *names): - """ - """ + """ """ raise RedisClusterException("method watch() is not implemented") def unwatch(self): - """ - """ + """ """ raise RedisClusterException("method unwatch() is not implemented") def script_load_for_pipeline(self, *args, **kwargs): - """ - """ + """ """ raise RedisClusterException( - "method script_load_for_pipeline() is not implemented") + "method script_load_for_pipeline() is not implemented" + ) def delete(self, *names): """ @@ -1913,10 +1936,10 @@ def delete(self, *names): """ if len(names) != 1: raise RedisClusterException( - "deleting multiple keys is not " - "implemented in pipeline command") + "deleting multiple keys is not " "implemented in pipeline command" + ) - return self.execute_command('DEL', names[0]) + return self.execute_command("DEL", names[0]) def block_pipeline_command(func): @@ -1928,7 +1951,8 @@ def block_pipeline_command(func): def inner(*args, **kwargs): raise RedisClusterException( f"ERROR: Calling pipelined function {func.__name__} is blocked when " - f"running redis in cluster mode...") + f"running redis in cluster mode..." + ) return inner @@ -1936,11 +1960,9 @@ def inner(*args, **kwargs): # Blocked pipeline commands ClusterPipeline.bitop = block_pipeline_command(RedisCluster.bitop) ClusterPipeline.brpoplpush = block_pipeline_command(RedisCluster.brpoplpush) -ClusterPipeline.client_getname = \ - block_pipeline_command(RedisCluster.client_getname) +ClusterPipeline.client_getname = block_pipeline_command(RedisCluster.client_getname) ClusterPipeline.client_list = block_pipeline_command(RedisCluster.client_list) -ClusterPipeline.client_setname = \ - block_pipeline_command(RedisCluster.client_setname) +ClusterPipeline.client_setname = block_pipeline_command(RedisCluster.client_setname) ClusterPipeline.config_set = block_pipeline_command(RedisCluster.config_set) ClusterPipeline.dbsize = block_pipeline_command(RedisCluster.dbsize) ClusterPipeline.flushall = block_pipeline_command(RedisCluster.flushall) @@ -1972,8 +1994,7 @@ def inner(*args, **kwargs): class PipelineCommand: - """ - """ + """ """ def __init__(self, args, options=None, position=None): self.args = args @@ -1987,20 +2008,17 @@ def __init__(self, args, options=None, position=None): class NodeCommands: - """ - """ + """ """ def __init__(self, parse_response, connection_pool, connection): - """ - """ + """ """ self.parse_response = parse_response self.connection_pool = connection_pool self.connection = connection self.commands = [] def append(self, c): - """ - """ + """ """ self.commands.append(c) def write(self): @@ -2019,14 +2037,14 @@ def write(self): # send all the commands and catch connection and timeout errors. try: connection.send_packed_command( - connection.pack_commands([c.args for c in commands])) + connection.pack_commands([c.args for c in commands]) + ) except (ConnectionError, TimeoutError) as e: for c in commands: c.result = e def read(self): - """ - """ + """ """ connection = self.connection for c in self.commands: @@ -2050,8 +2068,7 @@ def read(self): # explicitly open the connection and all will be well. if c.result is None: try: - c.result = self.parse_response( - connection, c.args[0], **c.options) + c.result = self.parse_response(connection, c.args[0], **c.options) except (ConnectionError, TimeoutError) as e: for c in self.commands: c.result = e diff --git a/redis/commands/__init__.py b/redis/commands/__init__.py index a4728d0ac4..bc1e78c60c 100644 --- a/redis/commands/__init__.py +++ b/redis/commands/__init__.py @@ -6,10 +6,10 @@ from .sentinel import SentinelCommands __all__ = [ - 'ClusterCommands', - 'CommandsParser', - 'CoreCommands', - 'list_or_args', - 'RedisModuleCommands', - 'SentinelCommands' + "ClusterCommands", + "CommandsParser", + "CoreCommands", + "list_or_args", + "RedisModuleCommands", + "SentinelCommands", ] diff --git a/redis/commands/cluster.py b/redis/commands/cluster.py index e6b0a08924..0df073ab16 100644 --- a/redis/commands/cluster.py +++ b/redis/commands/cluster.py @@ -1,9 +1,6 @@ -from redis.exceptions import ( - ConnectionError, - DataError, - RedisError, -) from redis.crc import key_slot +from redis.exceptions import ConnectionError, DataError, RedisError + from .core import DataAccessCommands from .helpers import list_or_args @@ -36,6 +33,7 @@ def mget_nonatomic(self, keys, *args): """ from redis.client import EMPTY_RESPONSE + options = {} if not args: options[EMPTY_RESPONSE] = [] @@ -50,8 +48,7 @@ def mget_nonatomic(self, keys, *args): # We must make sure that the keys are returned in order all_results = {} for slot_keys in slots_to_keys.values(): - slot_values = self.execute_command( - 'MGET', *slot_keys, **options) + slot_values = self.execute_command("MGET", *slot_keys, **options) slot_results = dict(zip(slot_keys, slot_values)) all_results.update(slot_results) @@ -83,7 +80,7 @@ def mset_nonatomic(self, mapping): # the results (one result per slot) res = [] for pairs in slots_to_pairs.values(): - res.append(self.execute_command('MSET', *pairs)) + res.append(self.execute_command("MSET", *pairs)) return res @@ -108,7 +105,7 @@ def exists(self, *keys): whole cluster. The keys are first split up into slots and then an EXISTS command is sent for every slot """ - return self._split_command_across_slots('EXISTS', *keys) + return self._split_command_across_slots("EXISTS", *keys) def delete(self, *keys): """ @@ -119,7 +116,7 @@ def delete(self, *keys): Non-existant keys are ignored. Returns the number of keys that were deleted. """ - return self._split_command_across_slots('DEL', *keys) + return self._split_command_across_slots("DEL", *keys) def touch(self, *keys): """ @@ -132,7 +129,7 @@ def touch(self, *keys): Non-existant keys are ignored. Returns the number of keys that were touched. """ - return self._split_command_across_slots('TOUCH', *keys) + return self._split_command_across_slots("TOUCH", *keys) def unlink(self, *keys): """ @@ -144,7 +141,7 @@ def unlink(self, *keys): Non-existant keys are ignored. Returns the number of keys that were unlinked. """ - return self._split_command_across_slots('UNLINK', *keys) + return self._split_command_across_slots("UNLINK", *keys) class ClusterManagementCommands: @@ -166,6 +163,7 @@ class ClusterManagementCommands: r.bgsave(target_nodes=primary) r.bgsave(target_nodes='primaries') """ + def bgsave(self, schedule=True, target_nodes=None): """ Tell the Redis server to save its data to disk. Unlike save(), @@ -174,9 +172,7 @@ def bgsave(self, schedule=True, target_nodes=None): pieces = [] if schedule: pieces.append("SCHEDULE") - return self.execute_command('BGSAVE', - *pieces, - target_nodes=target_nodes) + return self.execute_command("BGSAVE", *pieces, target_nodes=target_nodes) def client_getname(self, target_nodes=None): """ @@ -184,8 +180,7 @@ def client_getname(self, target_nodes=None): The result will be a dictionary with the IP and connection name. """ - return self.execute_command('CLIENT GETNAME', - target_nodes=target_nodes) + return self.execute_command("CLIENT GETNAME", target_nodes=target_nodes) def client_getredir(self, target_nodes=None): """Returns the ID (an integer) of the client to whom we are @@ -193,25 +188,29 @@ def client_getredir(self, target_nodes=None): see: https://redis.io/commands/client-getredir """ - return self.execute_command('CLIENT GETREDIR', - target_nodes=target_nodes) + return self.execute_command("CLIENT GETREDIR", target_nodes=target_nodes) def client_id(self, target_nodes=None): """Returns the current connection id""" - return self.execute_command('CLIENT ID', - target_nodes=target_nodes) + return self.execute_command("CLIENT ID", target_nodes=target_nodes) def client_info(self, target_nodes=None): """ Returns information and statistics about the current client connection. """ - return self.execute_command('CLIENT INFO', - target_nodes=target_nodes) + return self.execute_command("CLIENT INFO", target_nodes=target_nodes) - def client_kill_filter(self, _id=None, _type=None, addr=None, - skipme=None, laddr=None, user=None, - target_nodes=None): + def client_kill_filter( + self, + _id=None, + _type=None, + addr=None, + skipme=None, + laddr=None, + user=None, + target_nodes=None, + ): """ Disconnects client(s) using a variety of filter options :param id: Kills a client by its unique ID field @@ -226,35 +225,35 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, """ args = [] if _type is not None: - client_types = ('normal', 'master', 'slave', 'pubsub') + client_types = ("normal", "master", "slave", "pubsub") if str(_type).lower() not in client_types: raise DataError(f"CLIENT KILL type must be one of {client_types!r}") - args.extend((b'TYPE', _type)) + args.extend((b"TYPE", _type)) if skipme is not None: if not isinstance(skipme, bool): raise DataError("CLIENT KILL skipme must be a bool") if skipme: - args.extend((b'SKIPME', b'YES')) + args.extend((b"SKIPME", b"YES")) else: - args.extend((b'SKIPME', b'NO')) + args.extend((b"SKIPME", b"NO")) if _id is not None: - args.extend((b'ID', _id)) + args.extend((b"ID", _id)) if addr is not None: - args.extend((b'ADDR', addr)) + args.extend((b"ADDR", addr)) if laddr is not None: - args.extend((b'LADDR', laddr)) + args.extend((b"LADDR", laddr)) if user is not None: - args.extend((b'USER', user)) + args.extend((b"USER", user)) if not args: - raise DataError("CLIENT KILL ... ... " - " must specify at least one filter") - return self.execute_command('CLIENT KILL', *args, - target_nodes=target_nodes) + raise DataError( + "CLIENT KILL ... ... " + " must specify at least one filter" + ) + return self.execute_command("CLIENT KILL", *args, target_nodes=target_nodes) def client_kill(self, address, target_nodes=None): "Disconnects the client at ``address`` (ip:port)" - return self.execute_command('CLIENT KILL', address, - target_nodes=target_nodes) + return self.execute_command("CLIENT KILL", address, target_nodes=target_nodes) def client_list(self, _type=None, target_nodes=None): """ @@ -264,15 +263,13 @@ def client_list(self, _type=None, target_nodes=None): replica, pubsub) """ if _type is not None: - client_types = ('normal', 'master', 'replica', 'pubsub') + client_types = ("normal", "master", "replica", "pubsub") if str(_type).lower() not in client_types: raise DataError(f"CLIENT LIST _type must be one of {client_types!r}") - return self.execute_command('CLIENT LIST', - b'TYPE', - _type, - target_noes=target_nodes) - return self.execute_command('CLIENT LIST', - target_nodes=target_nodes) + return self.execute_command( + "CLIENT LIST", b"TYPE", _type, target_noes=target_nodes + ) + return self.execute_command("CLIENT LIST", target_nodes=target_nodes) def client_pause(self, timeout, target_nodes=None): """ @@ -281,8 +278,9 @@ def client_pause(self, timeout, target_nodes=None): """ if not isinstance(timeout, int): raise DataError("CLIENT PAUSE timeout must be an integer") - return self.execute_command('CLIENT PAUSE', str(timeout), - target_nodes=target_nodes) + return self.execute_command( + "CLIENT PAUSE", str(timeout), target_nodes=target_nodes + ) def client_reply(self, reply, target_nodes=None): """Enable and disable redis server replies. @@ -298,16 +296,14 @@ def client_reply(self, reply, target_nodes=None): conftest.py has a client with a timeout. See https://redis.io/commands/client-reply """ - replies = ['ON', 'OFF', 'SKIP'] + replies = ["ON", "OFF", "SKIP"] if reply not in replies: - raise DataError(f'CLIENT REPLY must be one of {replies!r}') - return self.execute_command("CLIENT REPLY", reply, - target_nodes=target_nodes) + raise DataError(f"CLIENT REPLY must be one of {replies!r}") + return self.execute_command("CLIENT REPLY", reply, target_nodes=target_nodes) def client_setname(self, name, target_nodes=None): "Sets the current connection name" - return self.execute_command('CLIENT SETNAME', name, - target_nodes=target_nodes) + return self.execute_command("CLIENT SETNAME", name, target_nodes=target_nodes) def client_trackinginfo(self, target_nodes=None): """ @@ -315,8 +311,7 @@ def client_trackinginfo(self, target_nodes=None): use of the server assisted client side cache. See https://redis.io/commands/client-trackinginfo """ - return self.execute_command('CLIENT TRACKINGINFO', - target_nodes=target_nodes) + return self.execute_command("CLIENT TRACKINGINFO", target_nodes=target_nodes) def client_unblock(self, client_id, error=False, target_nodes=None): """ @@ -325,56 +320,50 @@ def client_unblock(self, client_id, error=False, target_nodes=None): If ``error`` is False (default), the client is unblocked using the regular timeout mechanism. """ - args = ['CLIENT UNBLOCK', int(client_id)] + args = ["CLIENT UNBLOCK", int(client_id)] if error: - args.append(b'ERROR') + args.append(b"ERROR") return self.execute_command(*args, target_nodes=target_nodes) def client_unpause(self, target_nodes=None): """ Unpause all redis clients """ - return self.execute_command('CLIENT UNPAUSE', - target_nodes=target_nodes) + return self.execute_command("CLIENT UNPAUSE", target_nodes=target_nodes) def command(self, target_nodes=None): """ Returns dict reply of details about all Redis commands. """ - return self.execute_command('COMMAND', target_nodes=target_nodes) + return self.execute_command("COMMAND", target_nodes=target_nodes) def command_count(self, target_nodes=None): """ Returns Integer reply of number of total commands in this Redis server. """ - return self.execute_command('COMMAND COUNT', target_nodes=target_nodes) + return self.execute_command("COMMAND COUNT", target_nodes=target_nodes) def config_get(self, pattern="*", target_nodes=None): """ Return a dictionary of configuration based on the ``pattern`` """ - return self.execute_command('CONFIG GET', - pattern, - target_nodes=target_nodes) + return self.execute_command("CONFIG GET", pattern, target_nodes=target_nodes) def config_resetstat(self, target_nodes=None): """Reset runtime statistics""" - return self.execute_command('CONFIG RESETSTAT', - target_nodes=target_nodes) + return self.execute_command("CONFIG RESETSTAT", target_nodes=target_nodes) def config_rewrite(self, target_nodes=None): """ Rewrite config file with the minimal change to reflect running config. """ - return self.execute_command('CONFIG REWRITE', - target_nodes=target_nodes) + return self.execute_command("CONFIG REWRITE", target_nodes=target_nodes) def config_set(self, name, value, target_nodes=None): "Set config item ``name`` with ``value``" - return self.execute_command('CONFIG SET', - name, - value, - target_nodes=target_nodes) + return self.execute_command( + "CONFIG SET", name, value, target_nodes=target_nodes + ) def dbsize(self, target_nodes=None): """ @@ -383,8 +372,7 @@ def dbsize(self, target_nodes=None): :target_nodes: 'ClusterNode' or 'list(ClusterNodes)' The node/s to execute the command on """ - return self.execute_command('DBSIZE', - target_nodes=target_nodes) + return self.execute_command("DBSIZE", target_nodes=target_nodes) def debug_object(self, key): raise NotImplementedError( @@ -398,8 +386,7 @@ def debug_segfault(self): def echo(self, value, target_nodes): """Echo the string back from the server""" - return self.execute_command('ECHO', value, - target_nodes=target_nodes) + return self.execute_command("ECHO", value, target_nodes=target_nodes) def flushall(self, asynchronous=False, target_nodes=None): """ @@ -411,10 +398,8 @@ def flushall(self, asynchronous=False, target_nodes=None): """ args = [] if asynchronous: - args.append(b'ASYNC') - return self.execute_command('FLUSHALL', - *args, - target_nodes=target_nodes) + args.append(b"ASYNC") + return self.execute_command("FLUSHALL", *args, target_nodes=target_nodes) def flushdb(self, asynchronous=False, target_nodes=None): """ @@ -425,10 +410,8 @@ def flushdb(self, asynchronous=False, target_nodes=None): """ args = [] if asynchronous: - args.append(b'ASYNC') - return self.execute_command('FLUSHDB', - *args, - target_nodes=target_nodes) + args.append(b"ASYNC") + return self.execute_command("FLUSHDB", *args, target_nodes=target_nodes) def info(self, section=None, target_nodes=None): """ @@ -441,24 +424,20 @@ def info(self, section=None, target_nodes=None): and will generate ResponseError """ if section is None: - return self.execute_command('INFO', - target_nodes=target_nodes) + return self.execute_command("INFO", target_nodes=target_nodes) else: - return self.execute_command('INFO', - section, - target_nodes=target_nodes) + return self.execute_command("INFO", section, target_nodes=target_nodes) - def keys(self, pattern='*', target_nodes=None): + def keys(self, pattern="*", target_nodes=None): "Returns a list of keys matching ``pattern``" - return self.execute_command('KEYS', pattern, target_nodes=target_nodes) + return self.execute_command("KEYS", pattern, target_nodes=target_nodes) def lastsave(self, target_nodes=None): """ Return a Python datetime object representing the last time the Redis database was saved to disk """ - return self.execute_command('LASTSAVE', - target_nodes=target_nodes) + return self.execute_command("LASTSAVE", target_nodes=target_nodes) def memory_doctor(self): raise NotImplementedError( @@ -472,18 +451,15 @@ def memory_help(self): def memory_malloc_stats(self, target_nodes=None): """Return an internal statistics report from the memory allocator.""" - return self.execute_command('MEMORY MALLOC-STATS', - target_nodes=target_nodes) + return self.execute_command("MEMORY MALLOC-STATS", target_nodes=target_nodes) def memory_purge(self, target_nodes=None): """Attempts to purge dirty pages for reclamation by allocator""" - return self.execute_command('MEMORY PURGE', - target_nodes=target_nodes) + return self.execute_command("MEMORY PURGE", target_nodes=target_nodes) def memory_stats(self, target_nodes=None): """Return a dictionary of memory stats""" - return self.execute_command('MEMORY STATS', - target_nodes=target_nodes) + return self.execute_command("MEMORY STATS", target_nodes=target_nodes) def memory_usage(self, key, samples=None): """ @@ -496,12 +472,12 @@ def memory_usage(self, key, samples=None): """ args = [] if isinstance(samples, int): - args.extend([b'SAMPLES', samples]) - return self.execute_command('MEMORY USAGE', key, *args) + args.extend([b"SAMPLES", samples]) + return self.execute_command("MEMORY USAGE", key, *args) def object(self, infotype, key): """Return the encoding, idletime, or refcount about the key""" - return self.execute_command('OBJECT', infotype, key, infotype=infotype) + return self.execute_command("OBJECT", infotype, key, infotype=infotype) def ping(self, target_nodes=None): """ @@ -509,24 +485,22 @@ def ping(self, target_nodes=None): If no target nodes are specified, sent to all nodes and returns True if the ping was successful across all nodes. """ - return self.execute_command('PING', - target_nodes=target_nodes) + return self.execute_command("PING", target_nodes=target_nodes) def randomkey(self, target_nodes=None): """ Returns the name of a random key" """ - return self.execute_command('RANDOMKEY', target_nodes=target_nodes) + return self.execute_command("RANDOMKEY", target_nodes=target_nodes) def save(self, target_nodes=None): """ Tell the Redis server to save its data to disk, blocking until the save is complete """ - return self.execute_command('SAVE', target_nodes=target_nodes) + return self.execute_command("SAVE", target_nodes=target_nodes) - def scan(self, cursor=0, match=None, count=None, _type=None, - target_nodes=None): + def scan(self, cursor=0, match=None, count=None, _type=None, target_nodes=None): """ Incrementally return lists of key names. Also return a cursor indicating the scan position. @@ -543,12 +517,12 @@ def scan(self, cursor=0, match=None, count=None, _type=None, """ pieces = [cursor] if match is not None: - pieces.extend([b'MATCH', match]) + pieces.extend([b"MATCH", match]) if count is not None: - pieces.extend([b'COUNT', count]) + pieces.extend([b"COUNT", count]) if _type is not None: - pieces.extend([b'TYPE', _type]) - return self.execute_command('SCAN', *pieces, target_nodes=target_nodes) + pieces.extend([b"TYPE", _type]) + return self.execute_command("SCAN", *pieces, target_nodes=target_nodes) def scan_iter(self, match=None, count=None, _type=None, target_nodes=None): """ @@ -565,11 +539,15 @@ def scan_iter(self, match=None, count=None, _type=None, target_nodes=None): HASH, LIST, SET, STREAM, STRING, ZSET Additionally, Redis modules can expose other types as well. """ - cursor = '0' + cursor = "0" while cursor != 0: - cursor, data = self.scan(cursor=cursor, match=match, - count=count, _type=_type, - target_nodes=target_nodes) + cursor, data = self.scan( + cursor=cursor, + match=match, + count=count, + _type=_type, + target_nodes=target_nodes, + ) yield from data def shutdown(self, save=False, nosave=False, target_nodes=None): @@ -580,12 +558,12 @@ def shutdown(self, save=False, nosave=False, target_nodes=None): attempted. The "save" and "nosave" options cannot both be set. """ if save and nosave: - raise DataError('SHUTDOWN save and nosave cannot both be set') - args = ['SHUTDOWN'] + raise DataError("SHUTDOWN save and nosave cannot both be set") + args = ["SHUTDOWN"] if save: - args.append('SAVE') + args.append("SAVE") if nosave: - args.append('NOSAVE') + args.append("NOSAVE") try: self.execute_command(*args, target_nodes=target_nodes) except ConnectionError: @@ -598,26 +576,32 @@ def slowlog_get(self, num=None, target_nodes=None): Get the entries from the slowlog. If ``num`` is specified, get the most recent ``num`` items. """ - args = ['SLOWLOG GET'] + args = ["SLOWLOG GET"] if num is not None: args.append(num) - return self.execute_command(*args, - target_nodes=target_nodes) + return self.execute_command(*args, target_nodes=target_nodes) def slowlog_len(self, target_nodes=None): "Get the number of items in the slowlog" - return self.execute_command('SLOWLOG LEN', - target_nodes=target_nodes) + return self.execute_command("SLOWLOG LEN", target_nodes=target_nodes) def slowlog_reset(self, target_nodes=None): "Remove all items in the slowlog" - return self.execute_command('SLOWLOG RESET', - target_nodes=target_nodes) - - def stralgo(self, algo, value1, value2, specific_argument='strings', - len=False, idx=False, minmatchlen=None, withmatchlen=False, - target_nodes=None): + return self.execute_command("SLOWLOG RESET", target_nodes=target_nodes) + + def stralgo( + self, + algo, + value1, + value2, + specific_argument="strings", + len=False, + idx=False, + minmatchlen=None, + withmatchlen=False, + target_nodes=None, + ): """ Implements complex algorithms that operate on strings. Right now the only algorithm implemented is the LCS algorithm @@ -636,40 +620,45 @@ def stralgo(self, algo, value1, value2, specific_argument='strings', Can be provided only when ``idx`` set to True. """ # check validity - supported_algo = ['LCS'] + supported_algo = ["LCS"] if algo not in supported_algo: - supported_algos_str = ', '.join(supported_algo) + supported_algos_str = ", ".join(supported_algo) raise DataError(f"The supported algorithms are: {supported_algos_str}") - if specific_argument not in ['keys', 'strings']: + if specific_argument not in ["keys", "strings"]: raise DataError("specific_argument can be only keys or strings") if len and idx: raise DataError("len and idx cannot be provided together.") pieces = [algo, specific_argument.upper(), value1, value2] if len: - pieces.append(b'LEN') + pieces.append(b"LEN") if idx: - pieces.append(b'IDX') + pieces.append(b"IDX") try: int(minmatchlen) - pieces.extend([b'MINMATCHLEN', minmatchlen]) + pieces.extend([b"MINMATCHLEN", minmatchlen]) except TypeError: pass if withmatchlen: - pieces.append(b'WITHMATCHLEN') - if specific_argument == 'strings' and target_nodes is None: - target_nodes = 'default-node' - return self.execute_command('STRALGO', *pieces, len=len, idx=idx, - minmatchlen=minmatchlen, - withmatchlen=withmatchlen, - target_nodes=target_nodes) + pieces.append(b"WITHMATCHLEN") + if specific_argument == "strings" and target_nodes is None: + target_nodes = "default-node" + return self.execute_command( + "STRALGO", + *pieces, + len=len, + idx=idx, + minmatchlen=minmatchlen, + withmatchlen=withmatchlen, + target_nodes=target_nodes, + ) def time(self, target_nodes=None): """ Returns the server time as a 2-item tuple of ints: (seconds since epoch, microseconds into this second). """ - return self.execute_command('TIME', target_nodes=target_nodes) + return self.execute_command("TIME", target_nodes=target_nodes) def wait(self, num_replicas, timeout, target_nodes=None): """ @@ -680,9 +669,9 @@ def wait(self, num_replicas, timeout, target_nodes=None): If more than one target node are passed the result will be summed up """ - return self.execute_command('WAIT', num_replicas, - timeout, - target_nodes=target_nodes) + return self.execute_command( + "WAIT", num_replicas, timeout, target_nodes=target_nodes + ) class ClusterPubSubCommands: @@ -690,38 +679,44 @@ class ClusterPubSubCommands: Redis PubSub commands for RedisCluster use. see https://redis.io/topics/pubsub """ + def publish(self, channel, message, target_nodes=None): """ Publish ``message`` on ``channel``. Returns the number of subscribers the message was delivered to. """ - return self.execute_command('PUBLISH', channel, message, - target_nodes=target_nodes) + return self.execute_command( + "PUBLISH", channel, message, target_nodes=target_nodes + ) - def pubsub_channels(self, pattern='*', target_nodes=None): + def pubsub_channels(self, pattern="*", target_nodes=None): """ Return a list of channels that have at least one subscriber """ - return self.execute_command('PUBSUB CHANNELS', pattern, - target_nodes=target_nodes) + return self.execute_command( + "PUBSUB CHANNELS", pattern, target_nodes=target_nodes + ) def pubsub_numpat(self, target_nodes=None): """ Returns the number of subscriptions to patterns """ - return self.execute_command('PUBSUB NUMPAT', target_nodes=target_nodes) + return self.execute_command("PUBSUB NUMPAT", target_nodes=target_nodes) def pubsub_numsub(self, *args, target_nodes=None): """ Return a list of (channel, number of subscribers) tuples for each channel given in ``*args`` """ - return self.execute_command('PUBSUB NUMSUB', *args, - target_nodes=target_nodes) + return self.execute_command("PUBSUB NUMSUB", *args, target_nodes=target_nodes) -class ClusterCommands(ClusterManagementCommands, ClusterMultiKeyCommands, - ClusterPubSubCommands, DataAccessCommands): +class ClusterCommands( + ClusterManagementCommands, + ClusterMultiKeyCommands, + ClusterPubSubCommands, + DataAccessCommands, +): """ Redis Cluster commands @@ -738,6 +733,7 @@ class ClusterCommands(ClusterManagementCommands, ClusterMultiKeyCommands, for example: r.cluster_info(target_nodes='all') """ + def cluster_addslots(self, target_node, *slots): """ Assign new hash slots to receiving node. Sends to specified node. @@ -745,22 +741,23 @@ def cluster_addslots(self, target_node, *slots): :target_node: 'ClusterNode' The node to execute the command on """ - return self.execute_command('CLUSTER ADDSLOTS', *slots, - target_nodes=target_node) + return self.execute_command( + "CLUSTER ADDSLOTS", *slots, target_nodes=target_node + ) def cluster_countkeysinslot(self, slot_id): """ Return the number of local keys in the specified hash slot Send to node based on specified slot_id """ - return self.execute_command('CLUSTER COUNTKEYSINSLOT', slot_id) + return self.execute_command("CLUSTER COUNTKEYSINSLOT", slot_id) def cluster_count_failure_report(self, node_id): """ Return the number of failure reports active for a given node Sends to a random node """ - return self.execute_command('CLUSTER COUNT-FAILURE-REPORTS', node_id) + return self.execute_command("CLUSTER COUNT-FAILURE-REPORTS", node_id) def cluster_delslots(self, *slots): """ @@ -769,10 +766,7 @@ def cluster_delslots(self, *slots): Returns a list of the results for each processed slot. """ - return [ - self.execute_command('CLUSTER DELSLOTS', slot) - for slot in slots - ] + return [self.execute_command("CLUSTER DELSLOTS", slot) for slot in slots] def cluster_failover(self, target_node, option=None): """ @@ -783,15 +777,16 @@ def cluster_failover(self, target_node, option=None): The node to execute the command on """ if option: - if option.upper() not in ['FORCE', 'TAKEOVER']: + if option.upper() not in ["FORCE", "TAKEOVER"]: raise RedisError( - f'Invalid option for CLUSTER FAILOVER command: {option}') + f"Invalid option for CLUSTER FAILOVER command: {option}" + ) else: - return self.execute_command('CLUSTER FAILOVER', option, - target_nodes=target_node) + return self.execute_command( + "CLUSTER FAILOVER", option, target_nodes=target_node + ) else: - return self.execute_command('CLUSTER FAILOVER', - target_nodes=target_node) + return self.execute_command("CLUSTER FAILOVER", target_nodes=target_node) def cluster_info(self, target_nodes=None): """ @@ -799,22 +794,23 @@ def cluster_info(self, target_nodes=None): The command will be sent to a random node in the cluster if no target node is specified. """ - return self.execute_command('CLUSTER INFO', target_nodes=target_nodes) + return self.execute_command("CLUSTER INFO", target_nodes=target_nodes) def cluster_keyslot(self, key): """ Returns the hash slot of the specified key Sends to random node in the cluster """ - return self.execute_command('CLUSTER KEYSLOT', key) + return self.execute_command("CLUSTER KEYSLOT", key) def cluster_meet(self, host, port, target_nodes=None): """ Force a node cluster to handshake with another node. Sends to specified node. """ - return self.execute_command('CLUSTER MEET', host, port, - target_nodes=target_nodes) + return self.execute_command( + "CLUSTER MEET", host, port, target_nodes=target_nodes + ) def cluster_nodes(self): """ @@ -822,14 +818,15 @@ def cluster_nodes(self): Sends to random node in the cluster """ - return self.execute_command('CLUSTER NODES') + return self.execute_command("CLUSTER NODES") def cluster_replicate(self, target_nodes, node_id): """ Reconfigure a node as a slave of the specified master node """ - return self.execute_command('CLUSTER REPLICATE', node_id, - target_nodes=target_nodes) + return self.execute_command( + "CLUSTER REPLICATE", node_id, target_nodes=target_nodes + ) def cluster_reset(self, soft=True, target_nodes=None): """ @@ -838,29 +835,29 @@ def cluster_reset(self, soft=True, target_nodes=None): If 'soft' is True then it will send 'SOFT' argument If 'soft' is False then it will send 'HARD' argument """ - return self.execute_command('CLUSTER RESET', - b'SOFT' if soft else b'HARD', - target_nodes=target_nodes) + return self.execute_command( + "CLUSTER RESET", b"SOFT" if soft else b"HARD", target_nodes=target_nodes + ) def cluster_save_config(self, target_nodes=None): """ Forces the node to save cluster state on disk """ - return self.execute_command('CLUSTER SAVECONFIG', - target_nodes=target_nodes) + return self.execute_command("CLUSTER SAVECONFIG", target_nodes=target_nodes) def cluster_get_keys_in_slot(self, slot, num_keys): """ Returns the number of keys in the specified cluster slot """ - return self.execute_command('CLUSTER GETKEYSINSLOT', slot, num_keys) + return self.execute_command("CLUSTER GETKEYSINSLOT", slot, num_keys) def cluster_set_config_epoch(self, epoch, target_nodes=None): """ Set the configuration epoch in a new node """ - return self.execute_command('CLUSTER SET-CONFIG-EPOCH', epoch, - target_nodes=target_nodes) + return self.execute_command( + "CLUSTER SET-CONFIG-EPOCH", epoch, target_nodes=target_nodes + ) def cluster_setslot(self, target_node, node_id, slot_id, state): """ @@ -869,47 +866,48 @@ def cluster_setslot(self, target_node, node_id, slot_id, state): :target_node: 'ClusterNode' The node to execute the command on """ - if state.upper() in ('IMPORTING', 'NODE', 'MIGRATING'): - return self.execute_command('CLUSTER SETSLOT', slot_id, state, - node_id, target_nodes=target_node) - elif state.upper() == 'STABLE': - raise RedisError('For "stable" state please use ' - 'cluster_setslot_stable') + if state.upper() in ("IMPORTING", "NODE", "MIGRATING"): + return self.execute_command( + "CLUSTER SETSLOT", slot_id, state, node_id, target_nodes=target_node + ) + elif state.upper() == "STABLE": + raise RedisError('For "stable" state please use ' "cluster_setslot_stable") else: - raise RedisError(f'Invalid slot state: {state}') + raise RedisError(f"Invalid slot state: {state}") def cluster_setslot_stable(self, slot_id): """ Clears migrating / importing state from the slot. It determines by it self what node the slot is in and sends it there. """ - return self.execute_command('CLUSTER SETSLOT', slot_id, 'STABLE') + return self.execute_command("CLUSTER SETSLOT", slot_id, "STABLE") def cluster_replicas(self, node_id, target_nodes=None): """ Provides a list of replica nodes replicating from the specified primary target node. """ - return self.execute_command('CLUSTER REPLICAS', node_id, - target_nodes=target_nodes) + return self.execute_command( + "CLUSTER REPLICAS", node_id, target_nodes=target_nodes + ) def cluster_slots(self, target_nodes=None): """ Get array of Cluster slot to node mappings """ - return self.execute_command('CLUSTER SLOTS', target_nodes=target_nodes) + return self.execute_command("CLUSTER SLOTS", target_nodes=target_nodes) def readonly(self, target_nodes=None): """ Enables read queries. The command will be sent to the default cluster node if target_nodes is not specified. - """ - if target_nodes == 'replicas' or target_nodes == 'all': + """ + if target_nodes == "replicas" or target_nodes == "all": # read_from_replicas will only be enabled if the READONLY command # is sent to all replicas self.read_from_replicas = True - return self.execute_command('READONLY', target_nodes=target_nodes) + return self.execute_command("READONLY", target_nodes=target_nodes) def readwrite(self, target_nodes=None): """ @@ -919,4 +917,4 @@ def readwrite(self, target_nodes=None): """ # Reset read from replicas flag self.read_from_replicas = False - return self.execute_command('READWRITE', target_nodes=target_nodes) + return self.execute_command("READWRITE", target_nodes=target_nodes) diff --git a/redis/commands/core.py b/redis/commands/core.py index 0285f80e0f..688e1dda1b 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1,15 +1,11 @@ import datetime +import hashlib import time import warnings -import hashlib + +from redis.exceptions import ConnectionError, DataError, NoScriptError, RedisError from .helpers import list_or_args -from redis.exceptions import ( - ConnectionError, - DataError, - NoScriptError, - RedisError, -) class ACLCommands: @@ -17,6 +13,7 @@ class ACLCommands: Redis Access Control List (ACL) commands. see: https://redis.io/topics/acl """ + def acl_cat(self, category=None): """ Returns a list of categories or commands within a category. @@ -28,7 +25,7 @@ def acl_cat(self, category=None): For more information check https://redis.io/commands/acl-cat """ pieces = [category] if category else [] - return self.execute_command('ACL CAT', *pieces) + return self.execute_command("ACL CAT", *pieces) def acl_deluser(self, *username): """ @@ -36,7 +33,7 @@ def acl_deluser(self, *username): For more information check https://redis.io/commands/acl-deluser """ - return self.execute_command('ACL DELUSER', *username) + return self.execute_command("ACL DELUSER", *username) def acl_genpass(self, bits=None): """Generate a random password value. @@ -51,9 +48,10 @@ def acl_genpass(self, bits=None): if b < 0 or b > 4096: raise ValueError except ValueError: - raise DataError('genpass optionally accepts a bits argument, ' - 'between 0 and 4096.') - return self.execute_command('ACL GENPASS', *pieces) + raise DataError( + "genpass optionally accepts a bits argument, " "between 0 and 4096." + ) + return self.execute_command("ACL GENPASS", *pieces) def acl_getuser(self, username): """ @@ -63,7 +61,7 @@ def acl_getuser(self, username): For more information check https://redis.io/commands/acl-getuser """ - return self.execute_command('ACL GETUSER', username) + return self.execute_command("ACL GETUSER", username) def acl_help(self): """The ACL HELP command returns helpful text describing @@ -71,7 +69,7 @@ def acl_help(self): For more information check https://redis.io/commands/acl-help """ - return self.execute_command('ACL HELP') + return self.execute_command("ACL HELP") def acl_list(self): """ @@ -79,7 +77,7 @@ def acl_list(self): For more information check https://redis.io/commands/acl-list """ - return self.execute_command('ACL LIST') + return self.execute_command("ACL LIST") def acl_log(self, count=None): """ @@ -92,11 +90,10 @@ def acl_log(self, count=None): args = [] if count is not None: if not isinstance(count, int): - raise DataError('ACL LOG count must be an ' - 'integer') + raise DataError("ACL LOG count must be an " "integer") args.append(count) - return self.execute_command('ACL LOG', *args) + return self.execute_command("ACL LOG", *args) def acl_log_reset(self): """ @@ -105,8 +102,8 @@ def acl_log_reset(self): For more information check https://redis.io/commands/acl-log """ - args = [b'RESET'] - return self.execute_command('ACL LOG', *args) + args = [b"RESET"] + return self.execute_command("ACL LOG", *args) def acl_load(self): """ @@ -117,7 +114,7 @@ def acl_load(self): For more information check https://redis.io/commands/acl-load """ - return self.execute_command('ACL LOAD') + return self.execute_command("ACL LOAD") def acl_save(self): """ @@ -128,12 +125,22 @@ def acl_save(self): For more information check https://redis.io/commands/acl-save """ - return self.execute_command('ACL SAVE') - - def acl_setuser(self, username, enabled=False, nopass=False, - passwords=None, hashed_passwords=None, categories=None, - commands=None, keys=None, reset=False, reset_keys=False, - reset_passwords=False): + return self.execute_command("ACL SAVE") + + def acl_setuser( + self, + username, + enabled=False, + nopass=False, + passwords=None, + hashed_passwords=None, + categories=None, + commands=None, + keys=None, + reset=False, + reset_keys=False, + reset_passwords=False, + ): """ Create or update an ACL user. @@ -199,22 +206,23 @@ def acl_setuser(self, username, enabled=False, nopass=False, pieces = [username] if reset: - pieces.append(b'reset') + pieces.append(b"reset") if reset_keys: - pieces.append(b'resetkeys') + pieces.append(b"resetkeys") if reset_passwords: - pieces.append(b'resetpass') + pieces.append(b"resetpass") if enabled: - pieces.append(b'on') + pieces.append(b"on") else: - pieces.append(b'off') + pieces.append(b"off") if (passwords or hashed_passwords) and nopass: - raise DataError('Cannot set \'nopass\' and supply ' - '\'passwords\' or \'hashed_passwords\'') + raise DataError( + "Cannot set 'nopass' and supply " "'passwords' or 'hashed_passwords'" + ) if passwords: # as most users will have only one password, allow remove_passwords @@ -222,13 +230,15 @@ def acl_setuser(self, username, enabled=False, nopass=False, passwords = list_or_args(passwords, []) for i, password in enumerate(passwords): password = encoder.encode(password) - if password.startswith(b'+'): - pieces.append(b'>%s' % password[1:]) - elif password.startswith(b'-'): - pieces.append(b'<%s' % password[1:]) + if password.startswith(b"+"): + pieces.append(b">%s" % password[1:]) + elif password.startswith(b"-"): + pieces.append(b"<%s" % password[1:]) else: - raise DataError(f'Password {i} must be prefixed with a ' - f'"+" to add or a "-" to remove') + raise DataError( + f"Password {i} must be prefixed with a " + f'"+" to add or a "-" to remove' + ) if hashed_passwords: # as most users will have only one password, allow remove_passwords @@ -236,29 +246,31 @@ def acl_setuser(self, username, enabled=False, nopass=False, hashed_passwords = list_or_args(hashed_passwords, []) for i, hashed_password in enumerate(hashed_passwords): hashed_password = encoder.encode(hashed_password) - if hashed_password.startswith(b'+'): - pieces.append(b'#%s' % hashed_password[1:]) - elif hashed_password.startswith(b'-'): - pieces.append(b'!%s' % hashed_password[1:]) + if hashed_password.startswith(b"+"): + pieces.append(b"#%s" % hashed_password[1:]) + elif hashed_password.startswith(b"-"): + pieces.append(b"!%s" % hashed_password[1:]) else: - raise DataError(f'Hashed password {i} must be prefixed with a ' - f'"+" to add or a "-" to remove') + raise DataError( + f"Hashed password {i} must be prefixed with a " + f'"+" to add or a "-" to remove' + ) if nopass: - pieces.append(b'nopass') + pieces.append(b"nopass") if categories: for category in categories: category = encoder.encode(category) # categories can be prefixed with one of (+@, +, -@, -) - if category.startswith(b'+@'): + if category.startswith(b"+@"): pieces.append(category) - elif category.startswith(b'+'): - pieces.append(b'+@%s' % category[1:]) - elif category.startswith(b'-@'): + elif category.startswith(b"+"): + pieces.append(b"+@%s" % category[1:]) + elif category.startswith(b"-@"): pieces.append(category) - elif category.startswith(b'-'): - pieces.append(b'-@%s' % category[1:]) + elif category.startswith(b"-"): + pieces.append(b"-@%s" % category[1:]) else: raise DataError( f'Category "{encoder.decode(category, force=True)}" ' @@ -267,7 +279,7 @@ def acl_setuser(self, username, enabled=False, nopass=False, if commands: for cmd in commands: cmd = encoder.encode(cmd) - if not cmd.startswith(b'+') and not cmd.startswith(b'-'): + if not cmd.startswith(b"+") and not cmd.startswith(b"-"): raise DataError( f'Command "{encoder.decode(cmd, force=True)}" ' 'must be prefixed with "+" or "-"' @@ -277,35 +289,36 @@ def acl_setuser(self, username, enabled=False, nopass=False, if keys: for key in keys: key = encoder.encode(key) - pieces.append(b'~%s' % key) + pieces.append(b"~%s" % key) - return self.execute_command('ACL SETUSER', *pieces) + return self.execute_command("ACL SETUSER", *pieces) def acl_users(self): """Returns a list of all registered users on the server. For more information check https://redis.io/commands/acl-users """ - return self.execute_command('ACL USERS') + return self.execute_command("ACL USERS") def acl_whoami(self): """Get the username for the current connection For more information check https://redis.io/commands/acl-whoami """ - return self.execute_command('ACL WHOAMI') + return self.execute_command("ACL WHOAMI") class ManagementCommands: """ Redis management commands """ + def bgrewriteaof(self): """Tell the Redis server to rewrite the AOF file from data in memory. For more information check https://redis.io/commands/bgrewriteaof """ - return self.execute_command('BGREWRITEAOF') + return self.execute_command("BGREWRITEAOF") def bgsave(self, schedule=True): """ @@ -317,17 +330,18 @@ def bgsave(self, schedule=True): pieces = [] if schedule: pieces.append("SCHEDULE") - return self.execute_command('BGSAVE', *pieces) + return self.execute_command("BGSAVE", *pieces) def client_kill(self, address): """Disconnects the client at ``address`` (ip:port) For more information check https://redis.io/commands/client-kill """ - return self.execute_command('CLIENT KILL', address) + return self.execute_command("CLIENT KILL", address) - def client_kill_filter(self, _id=None, _type=None, addr=None, - skipme=None, laddr=None, user=None): + def client_kill_filter( + self, _id=None, _type=None, addr=None, skipme=None, laddr=None, user=None + ): """ Disconnects client(s) using a variety of filter options :param id: Kills a client by its unique ID field @@ -342,29 +356,31 @@ def client_kill_filter(self, _id=None, _type=None, addr=None, """ args = [] if _type is not None: - client_types = ('normal', 'master', 'slave', 'pubsub') + client_types = ("normal", "master", "slave", "pubsub") if str(_type).lower() not in client_types: raise DataError(f"CLIENT KILL type must be one of {client_types!r}") - args.extend((b'TYPE', _type)) + args.extend((b"TYPE", _type)) if skipme is not None: if not isinstance(skipme, bool): raise DataError("CLIENT KILL skipme must be a bool") if skipme: - args.extend((b'SKIPME', b'YES')) + args.extend((b"SKIPME", b"YES")) else: - args.extend((b'SKIPME', b'NO')) + args.extend((b"SKIPME", b"NO")) if _id is not None: - args.extend((b'ID', _id)) + args.extend((b"ID", _id)) if addr is not None: - args.extend((b'ADDR', addr)) + args.extend((b"ADDR", addr)) if laddr is not None: - args.extend((b'LADDR', laddr)) + args.extend((b"LADDR", laddr)) if user is not None: - args.extend((b'USER', user)) + args.extend((b"USER", user)) if not args: - raise DataError("CLIENT KILL ... ... " - " must specify at least one filter") - return self.execute_command('CLIENT KILL', *args) + raise DataError( + "CLIENT KILL ... ... " + " must specify at least one filter" + ) + return self.execute_command("CLIENT KILL", *args) def client_info(self): """ @@ -373,7 +389,7 @@ def client_info(self): For more information check https://redis.io/commands/client-info """ - return self.execute_command('CLIENT INFO') + return self.execute_command("CLIENT INFO") def client_list(self, _type=None, client_id=[]): """ @@ -387,17 +403,17 @@ def client_list(self, _type=None, client_id=[]): """ args = [] if _type is not None: - client_types = ('normal', 'master', 'replica', 'pubsub') + client_types = ("normal", "master", "replica", "pubsub") if str(_type).lower() not in client_types: raise DataError(f"CLIENT LIST _type must be one of {client_types!r}") - args.append(b'TYPE') + args.append(b"TYPE") args.append(_type) if not isinstance(client_id, list): raise DataError("client_id must be a list") if client_id != []: args.append(b"ID") - args.append(' '.join(client_id)) - return self.execute_command('CLIENT LIST', *args) + args.append(" ".join(client_id)) + return self.execute_command("CLIENT LIST", *args) def client_getname(self): """ @@ -405,7 +421,7 @@ def client_getname(self): For more information check https://redis.io/commands/client-getname """ - return self.execute_command('CLIENT GETNAME') + return self.execute_command("CLIENT GETNAME") def client_getredir(self): """ @@ -414,7 +430,7 @@ def client_getredir(self): see: https://redis.io/commands/client-getredir """ - return self.execute_command('CLIENT GETREDIR') + return self.execute_command("CLIENT GETREDIR") def client_reply(self, reply): """ @@ -432,9 +448,9 @@ def client_reply(self, reply): See https://redis.io/commands/client-reply """ - replies = ['ON', 'OFF', 'SKIP'] + replies = ["ON", "OFF", "SKIP"] if reply not in replies: - raise DataError(f'CLIENT REPLY must be one of {replies!r}') + raise DataError(f"CLIENT REPLY must be one of {replies!r}") return self.execute_command("CLIENT REPLY", reply) def client_id(self): @@ -443,7 +459,7 @@ def client_id(self): For more information check https://redis.io/commands/client-id """ - return self.execute_command('CLIENT ID') + return self.execute_command("CLIENT ID") def client_trackinginfo(self): """ @@ -452,7 +468,7 @@ def client_trackinginfo(self): See https://redis.io/commands/client-trackinginfo """ - return self.execute_command('CLIENT TRACKINGINFO') + return self.execute_command("CLIENT TRACKINGINFO") def client_setname(self, name): """ @@ -460,7 +476,7 @@ def client_setname(self, name): For more information check https://redis.io/commands/client-setname """ - return self.execute_command('CLIENT SETNAME', name) + return self.execute_command("CLIENT SETNAME", name) def client_unblock(self, client_id, error=False): """ @@ -471,9 +487,9 @@ def client_unblock(self, client_id, error=False): For more information check https://redis.io/commands/client-unblock """ - args = ['CLIENT UNBLOCK', int(client_id)] + args = ["CLIENT UNBLOCK", int(client_id)] if error: - args.append(b'ERROR') + args.append(b"ERROR") return self.execute_command(*args) def client_pause(self, timeout): @@ -485,7 +501,7 @@ def client_pause(self, timeout): """ if not isinstance(timeout, int): raise DataError("CLIENT PAUSE timeout must be an integer") - return self.execute_command('CLIENT PAUSE', str(timeout)) + return self.execute_command("CLIENT PAUSE", str(timeout)) def client_unpause(self): """ @@ -493,7 +509,7 @@ def client_unpause(self): For more information check https://redis.io/commands/client-unpause """ - return self.execute_command('CLIENT UNPAUSE') + return self.execute_command("CLIENT UNPAUSE") def command_info(self): raise NotImplementedError( @@ -501,7 +517,7 @@ def command_info(self): ) def command_count(self): - return self.execute_command('COMMAND COUNT') + return self.execute_command("COMMAND COUNT") def readwrite(self): """ @@ -509,7 +525,7 @@ def readwrite(self): For more information check https://redis.io/commands/readwrite """ - return self.execute_command('READWRITE') + return self.execute_command("READWRITE") def readonly(self): """ @@ -517,7 +533,7 @@ def readonly(self): For more information check https://redis.io/commands/readonly """ - return self.execute_command('READONLY') + return self.execute_command("READONLY") def config_get(self, pattern="*"): """ @@ -525,14 +541,14 @@ def config_get(self, pattern="*"): For more information check https://redis.io/commands/config-get """ - return self.execute_command('CONFIG GET', pattern) + return self.execute_command("CONFIG GET", pattern) def config_set(self, name, value): """Set config item ``name`` with ``value`` For more information check https://redis.io/commands/config-set """ - return self.execute_command('CONFIG SET', name, value) + return self.execute_command("CONFIG SET", name, value) def config_resetstat(self): """ @@ -540,7 +556,7 @@ def config_resetstat(self): For more information check https://redis.io/commands/config-resetstat """ - return self.execute_command('CONFIG RESETSTAT') + return self.execute_command("CONFIG RESETSTAT") def config_rewrite(self): """ @@ -548,10 +564,10 @@ def config_rewrite(self): For more information check https://redis.io/commands/config-rewrite """ - return self.execute_command('CONFIG REWRITE') + return self.execute_command("CONFIG REWRITE") def cluster(self, cluster_arg, *args): - return self.execute_command(f'CLUSTER {cluster_arg.upper()}', *args) + return self.execute_command(f"CLUSTER {cluster_arg.upper()}", *args) def dbsize(self): """ @@ -559,7 +575,7 @@ def dbsize(self): For more information check https://redis.io/commands/dbsize """ - return self.execute_command('DBSIZE') + return self.execute_command("DBSIZE") def debug_object(self, key): """ @@ -567,7 +583,7 @@ def debug_object(self, key): For more information check https://redis.io/commands/debug-object """ - return self.execute_command('DEBUG OBJECT', key) + return self.execute_command("DEBUG OBJECT", key) def debug_segfault(self): raise NotImplementedError( @@ -584,7 +600,7 @@ def echo(self, value): For more information check https://redis.io/commands/echo """ - return self.execute_command('ECHO', value) + return self.execute_command("ECHO", value) def flushall(self, asynchronous=False): """ @@ -597,8 +613,8 @@ def flushall(self, asynchronous=False): """ args = [] if asynchronous: - args.append(b'ASYNC') - return self.execute_command('FLUSHALL', *args) + args.append(b"ASYNC") + return self.execute_command("FLUSHALL", *args) def flushdb(self, asynchronous=False): """ @@ -611,8 +627,8 @@ def flushdb(self, asynchronous=False): """ args = [] if asynchronous: - args.append(b'ASYNC') - return self.execute_command('FLUSHDB', *args) + args.append(b"ASYNC") + return self.execute_command("FLUSHDB", *args) def swapdb(self, first, second): """ @@ -620,7 +636,7 @@ def swapdb(self, first, second): For more information check https://redis.io/commands/swapdb """ - return self.execute_command('SWAPDB', first, second) + return self.execute_command("SWAPDB", first, second) def info(self, section=None): """ @@ -635,9 +651,9 @@ def info(self, section=None): For more information check https://redis.io/commands/info """ if section is None: - return self.execute_command('INFO') + return self.execute_command("INFO") else: - return self.execute_command('INFO', section) + return self.execute_command("INFO", section) def lastsave(self): """ @@ -646,7 +662,7 @@ def lastsave(self): For more information check https://redis.io/commands/lastsave """ - return self.execute_command('LASTSAVE') + return self.execute_command("LASTSAVE") def lolwut(self, *version_numbers): """ @@ -655,12 +671,21 @@ def lolwut(self, *version_numbers): See: https://redis.io/commands/lolwut """ if version_numbers: - return self.execute_command('LOLWUT VERSION', *version_numbers) + return self.execute_command("LOLWUT VERSION", *version_numbers) else: - return self.execute_command('LOLWUT') - - def migrate(self, host, port, keys, destination_db, timeout, - copy=False, replace=False, auth=None): + return self.execute_command("LOLWUT") + + def migrate( + self, + host, + port, + keys, + destination_db, + timeout, + copy=False, + replace=False, + auth=None, + ): """ Migrate 1 or more keys from the current Redis server to a different server specified by the ``host``, ``port`` and ``destination_db``. @@ -682,25 +707,26 @@ def migrate(self, host, port, keys, destination_db, timeout, """ keys = list_or_args(keys, []) if not keys: - raise DataError('MIGRATE requires at least one key') + raise DataError("MIGRATE requires at least one key") pieces = [] if copy: - pieces.append(b'COPY') + pieces.append(b"COPY") if replace: - pieces.append(b'REPLACE') + pieces.append(b"REPLACE") if auth: - pieces.append(b'AUTH') + pieces.append(b"AUTH") pieces.append(auth) - pieces.append(b'KEYS') + pieces.append(b"KEYS") pieces.extend(keys) - return self.execute_command('MIGRATE', host, port, '', destination_db, - timeout, *pieces) + return self.execute_command( + "MIGRATE", host, port, "", destination_db, timeout, *pieces + ) def object(self, infotype, key): """ Return the encoding, idletime, or refcount about the key """ - return self.execute_command('OBJECT', infotype, key, infotype=infotype) + return self.execute_command("OBJECT", infotype, key, infotype=infotype) def memory_doctor(self): raise NotImplementedError( @@ -726,7 +752,7 @@ def memory_stats(self): For more information check https://redis.io/commands/memory-stats """ - return self.execute_command('MEMORY STATS') + return self.execute_command("MEMORY STATS") def memory_malloc_stats(self): """ @@ -734,7 +760,7 @@ def memory_malloc_stats(self): See: https://redis.io/commands/memory-malloc-stats """ - return self.execute_command('MEMORY MALLOC-STATS') + return self.execute_command("MEMORY MALLOC-STATS") def memory_usage(self, key, samples=None): """ @@ -749,8 +775,8 @@ def memory_usage(self, key, samples=None): """ args = [] if isinstance(samples, int): - args.extend([b'SAMPLES', samples]) - return self.execute_command('MEMORY USAGE', key, *args) + args.extend([b"SAMPLES", samples]) + return self.execute_command("MEMORY USAGE", key, *args) def memory_purge(self): """ @@ -758,7 +784,7 @@ def memory_purge(self): For more information check https://redis.io/commands/memory-purge """ - return self.execute_command('MEMORY PURGE') + return self.execute_command("MEMORY PURGE") def ping(self): """ @@ -766,7 +792,7 @@ def ping(self): For more information check https://redis.io/commands/ping """ - return self.execute_command('PING') + return self.execute_command("PING") def quit(self): """ @@ -774,7 +800,7 @@ def quit(self): For more information check https://redis.io/commands/quit """ - return self.execute_command('QUIT') + return self.execute_command("QUIT") def replicaof(self, *args): """ @@ -785,7 +811,7 @@ def replicaof(self, *args): For more information check https://redis.io/commands/replicaof """ - return self.execute_command('REPLICAOF', *args) + return self.execute_command("REPLICAOF", *args) def save(self): """ @@ -794,7 +820,7 @@ def save(self): For more information check https://redis.io/commands/save """ - return self.execute_command('SAVE') + return self.execute_command("SAVE") def shutdown(self, save=False, nosave=False): """Shutdown the Redis server. If Redis has persistence configured, @@ -806,12 +832,12 @@ def shutdown(self, save=False, nosave=False): For more information check https://redis.io/commands/shutdown """ if save and nosave: - raise DataError('SHUTDOWN save and nosave cannot both be set') - args = ['SHUTDOWN'] + raise DataError("SHUTDOWN save and nosave cannot both be set") + args = ["SHUTDOWN"] if save: - args.append('SAVE') + args.append("SAVE") if nosave: - args.append('NOSAVE') + args.append("NOSAVE") try: self.execute_command(*args) except ConnectionError: @@ -828,8 +854,8 @@ def slaveof(self, host=None, port=None): For more information check https://redis.io/commands/slaveof """ if host is None and port is None: - return self.execute_command('SLAVEOF', b'NO', b'ONE') - return self.execute_command('SLAVEOF', host, port) + return self.execute_command("SLAVEOF", b"NO", b"ONE") + return self.execute_command("SLAVEOF", host, port) def slowlog_get(self, num=None): """ @@ -838,11 +864,12 @@ def slowlog_get(self, num=None): For more information check https://redis.io/commands/slowlog-get """ - args = ['SLOWLOG GET'] + args = ["SLOWLOG GET"] if num is not None: args.append(num) decode_responses = self.connection_pool.connection_kwargs.get( - 'decode_responses', False) + "decode_responses", False + ) return self.execute_command(*args, decode_responses=decode_responses) def slowlog_len(self): @@ -851,7 +878,7 @@ def slowlog_len(self): For more information check https://redis.io/commands/slowlog-len """ - return self.execute_command('SLOWLOG LEN') + return self.execute_command("SLOWLOG LEN") def slowlog_reset(self): """ @@ -859,7 +886,7 @@ def slowlog_reset(self): For more information check https://redis.io/commands/slowlog-reset """ - return self.execute_command('SLOWLOG RESET') + return self.execute_command("SLOWLOG RESET") def time(self): """ @@ -868,7 +895,7 @@ def time(self): For more information check https://redis.io/commands/time """ - return self.execute_command('TIME') + return self.execute_command("TIME") def wait(self, num_replicas, timeout): """ @@ -879,13 +906,14 @@ def wait(self, num_replicas, timeout): For more information check https://redis.io/commands/wait """ - return self.execute_command('WAIT', num_replicas, timeout) + return self.execute_command("WAIT", num_replicas, timeout) class BasicKeyCommands: """ Redis basic key-based commands """ + def append(self, key, value): """ Appends the string ``value`` to the value at ``key``. If ``key`` @@ -894,7 +922,7 @@ def append(self, key, value): For more information check https://redis.io/commands/append """ - return self.execute_command('APPEND', key, value) + return self.execute_command("APPEND", key, value) def bitcount(self, key, start=None, end=None): """ @@ -907,10 +935,9 @@ def bitcount(self, key, start=None, end=None): if start is not None and end is not None: params.append(start) params.append(end) - elif (start is not None and end is None) or \ - (end is not None and start is None): + elif (start is not None and end is None) or (end is not None and start is None): raise DataError("Both start and end must be specified") - return self.execute_command('BITCOUNT', *params) + return self.execute_command("BITCOUNT", *params) def bitfield(self, key, default_overflow=None): """ @@ -928,7 +955,7 @@ def bitop(self, operation, dest, *keys): For more information check https://redis.io/commands/bitop """ - return self.execute_command('BITOP', operation, dest, *keys) + return self.execute_command("BITOP", operation, dest, *keys) def bitpos(self, key, bit, start=None, end=None): """ @@ -940,7 +967,7 @@ def bitpos(self, key, bit, start=None, end=None): For more information check https://redis.io/commands/bitpos """ if bit not in (0, 1): - raise DataError('bit must be 0 or 1') + raise DataError("bit must be 0 or 1") params = [key, bit] start is not None and params.append(start) @@ -948,9 +975,8 @@ def bitpos(self, key, bit, start=None, end=None): if start is not None and end is not None: params.append(end) elif start is None and end is not None: - raise DataError("start argument is not set, " - "when end is specified") - return self.execute_command('BITPOS', *params) + raise DataError("start argument is not set, " "when end is specified") + return self.execute_command("BITPOS", *params) def copy(self, source, destination, destination_db=None, replace=False): """ @@ -970,7 +996,7 @@ def copy(self, source, destination, destination_db=None, replace=False): params.extend(["DB", destination_db]) if replace: params.append("REPLACE") - return self.execute_command('COPY', *params) + return self.execute_command("COPY", *params) def decr(self, name, amount=1): """ @@ -990,13 +1016,13 @@ def decrby(self, name, amount=1): For more information check https://redis.io/commands/decrby """ - return self.execute_command('DECRBY', name, amount) + return self.execute_command("DECRBY", name, amount) def delete(self, *names): """ Delete one or more keys specified by ``names`` """ - return self.execute_command('DEL', *names) + return self.execute_command("DEL", *names) def __delitem__(self, name): self.delete(name) @@ -1009,9 +1035,10 @@ def dump(self, name): For more information check https://redis.io/commands/dump """ from redis.client import NEVER_DECODE + options = {} options[NEVER_DECODE] = [] - return self.execute_command('DUMP', name, **options) + return self.execute_command("DUMP", name, **options) def exists(self, *names): """ @@ -1019,7 +1046,8 @@ def exists(self, *names): For more information check https://redis.io/commands/exists """ - return self.execute_command('EXISTS', *names) + return self.execute_command("EXISTS", *names) + __contains__ = exists def expire(self, name, time): @@ -1031,7 +1059,7 @@ def expire(self, name, time): """ if isinstance(time, datetime.timedelta): time = int(time.total_seconds()) - return self.execute_command('EXPIRE', name, time) + return self.execute_command("EXPIRE", name, time) def expireat(self, name, when): """ @@ -1042,7 +1070,7 @@ def expireat(self, name, when): """ if isinstance(when, datetime.datetime): when = int(time.mktime(when.timetuple())) - return self.execute_command('EXPIREAT', name, when) + return self.execute_command("EXPIREAT", name, when) def get(self, name): """ @@ -1050,7 +1078,7 @@ def get(self, name): For more information check https://redis.io/commands/get """ - return self.execute_command('GET', name) + return self.execute_command("GET", name) def getdel(self, name): """ @@ -1061,10 +1089,9 @@ def getdel(self, name): For more information check https://redis.io/commands/getdel """ - return self.execute_command('GETDEL', name) + return self.execute_command("GETDEL", name) - def getex(self, name, - ex=None, px=None, exat=None, pxat=None, persist=False): + def getex(self, name, ex=None, px=None, exat=None, pxat=None, persist=False): """ Get the value of key and optionally set its expiration. GETEX is similar to GET, but is a write command with @@ -1088,38 +1115,40 @@ def getex(self, name, opset = {ex, px, exat, pxat} if len(opset) > 2 or len(opset) > 1 and persist: - raise DataError("``ex``, ``px``, ``exat``, ``pxat``, " - "and ``persist`` are mutually exclusive.") + raise DataError( + "``ex``, ``px``, ``exat``, ``pxat``, " + "and ``persist`` are mutually exclusive." + ) pieces = [] # similar to set command if ex is not None: - pieces.append('EX') + pieces.append("EX") if isinstance(ex, datetime.timedelta): ex = int(ex.total_seconds()) pieces.append(ex) if px is not None: - pieces.append('PX') + pieces.append("PX") if isinstance(px, datetime.timedelta): px = int(px.total_seconds() * 1000) pieces.append(px) # similar to pexpireat command if exat is not None: - pieces.append('EXAT') + pieces.append("EXAT") if isinstance(exat, datetime.datetime): s = int(exat.microsecond / 1000000) exat = int(time.mktime(exat.timetuple())) + s pieces.append(exat) if pxat is not None: - pieces.append('PXAT') + pieces.append("PXAT") if isinstance(pxat, datetime.datetime): ms = int(pxat.microsecond / 1000) pxat = int(time.mktime(pxat.timetuple())) * 1000 + ms pieces.append(pxat) if persist: - pieces.append('PERSIST') + pieces.append("PERSIST") - return self.execute_command('GETEX', name, *pieces) + return self.execute_command("GETEX", name, *pieces) def __getitem__(self, name): """ @@ -1137,7 +1166,7 @@ def getbit(self, name, offset): For more information check https://redis.io/commands/getbit """ - return self.execute_command('GETBIT', name, offset) + return self.execute_command("GETBIT", name, offset) def getrange(self, key, start, end): """ @@ -1146,7 +1175,7 @@ def getrange(self, key, start, end): For more information check https://redis.io/commands/getrange """ - return self.execute_command('GETRANGE', key, start, end) + return self.execute_command("GETRANGE", key, start, end) def getset(self, name, value): """ @@ -1158,7 +1187,7 @@ def getset(self, name, value): For more information check https://redis.io/commands/getset """ - return self.execute_command('GETSET', name, value) + return self.execute_command("GETSET", name, value) def incr(self, name, amount=1): """ @@ -1178,7 +1207,7 @@ def incrby(self, name, amount=1): """ # An alias for ``incr()``, because it is already implemented # as INCRBY redis command. - return self.execute_command('INCRBY', name, amount) + return self.execute_command("INCRBY", name, amount) def incrbyfloat(self, name, amount=1.0): """ @@ -1187,15 +1216,15 @@ def incrbyfloat(self, name, amount=1.0): For more information check https://redis.io/commands/incrbyfloat """ - return self.execute_command('INCRBYFLOAT', name, amount) + return self.execute_command("INCRBYFLOAT", name, amount) - def keys(self, pattern='*'): + def keys(self, pattern="*"): """ Returns a list of keys matching ``pattern`` For more information check https://redis.io/commands/keys """ - return self.execute_command('KEYS', pattern) + return self.execute_command("KEYS", pattern) def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): """ @@ -1208,8 +1237,7 @@ def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): params = [first_list, second_list, src, dest] return self.execute_command("LMOVE", *params) - def blmove(self, first_list, second_list, timeout, - src="LEFT", dest="RIGHT"): + def blmove(self, first_list, second_list, timeout, src="LEFT", dest="RIGHT"): """ Blocking version of lmove. @@ -1225,11 +1253,12 @@ def mget(self, keys, *args): For more information check https://redis.io/commands/mget """ from redis.client import EMPTY_RESPONSE + args = list_or_args(keys, args) options = {} if not args: options[EMPTY_RESPONSE] = [] - return self.execute_command('MGET', *args, **options) + return self.execute_command("MGET", *args, **options) def mset(self, mapping): """ @@ -1242,7 +1271,7 @@ def mset(self, mapping): items = [] for pair in mapping.items(): items.extend(pair) - return self.execute_command('MSET', *items) + return self.execute_command("MSET", *items) def msetnx(self, mapping): """ @@ -1256,7 +1285,7 @@ def msetnx(self, mapping): items = [] for pair in mapping.items(): items.extend(pair) - return self.execute_command('MSETNX', *items) + return self.execute_command("MSETNX", *items) def move(self, name, db): """ @@ -1264,7 +1293,7 @@ def move(self, name, db): For more information check https://redis.io/commands/move """ - return self.execute_command('MOVE', name, db) + return self.execute_command("MOVE", name, db) def persist(self, name): """ @@ -1272,7 +1301,7 @@ def persist(self, name): For more information check https://redis.io/commands/persist """ - return self.execute_command('PERSIST', name) + return self.execute_command("PERSIST", name) def pexpire(self, name, time): """ @@ -1284,7 +1313,7 @@ def pexpire(self, name, time): """ if isinstance(time, datetime.timedelta): time = int(time.total_seconds() * 1000) - return self.execute_command('PEXPIRE', name, time) + return self.execute_command("PEXPIRE", name, time) def pexpireat(self, name, when): """ @@ -1297,7 +1326,7 @@ def pexpireat(self, name, when): if isinstance(when, datetime.datetime): ms = int(when.microsecond / 1000) when = int(time.mktime(when.timetuple())) * 1000 + ms - return self.execute_command('PEXPIREAT', name, when) + return self.execute_command("PEXPIREAT", name, when) def psetex(self, name, time_ms, value): """ @@ -1309,7 +1338,7 @@ def psetex(self, name, time_ms, value): """ if isinstance(time_ms, datetime.timedelta): time_ms = int(time_ms.total_seconds() * 1000) - return self.execute_command('PSETEX', name, time_ms, value) + return self.execute_command("PSETEX", name, time_ms, value) def pttl(self, name): """ @@ -1317,7 +1346,7 @@ def pttl(self, name): For more information check https://redis.io/commands/pttl """ - return self.execute_command('PTTL', name) + return self.execute_command("PTTL", name) def hrandfield(self, key, count=None, withvalues=False): """ @@ -1347,7 +1376,7 @@ def randomkey(self): For more information check https://redis.io/commands/randomkey """ - return self.execute_command('RANDOMKEY') + return self.execute_command("RANDOMKEY") def rename(self, src, dst): """ @@ -1355,7 +1384,7 @@ def rename(self, src, dst): For more information check https://redis.io/commands/rename """ - return self.execute_command('RENAME', src, dst) + return self.execute_command("RENAME", src, dst) def renamenx(self, src, dst): """ @@ -1363,10 +1392,18 @@ def renamenx(self, src, dst): For more information check https://redis.io/commands/renamenx """ - return self.execute_command('RENAMENX', src, dst) + return self.execute_command("RENAMENX", src, dst) - def restore(self, name, ttl, value, replace=False, absttl=False, - idletime=None, frequency=None): + def restore( + self, + name, + ttl, + value, + replace=False, + absttl=False, + idletime=None, + frequency=None, + ): """ Create a key using the provided serialized value, previously obtained using DUMP. @@ -1388,28 +1425,38 @@ def restore(self, name, ttl, value, replace=False, absttl=False, """ params = [name, ttl, value] if replace: - params.append('REPLACE') + params.append("REPLACE") if absttl: - params.append('ABSTTL') + params.append("ABSTTL") if idletime is not None: - params.append('IDLETIME') + params.append("IDLETIME") try: params.append(int(idletime)) except ValueError: raise DataError("idletimemust be an integer") if frequency is not None: - params.append('FREQ') + params.append("FREQ") try: params.append(int(frequency)) except ValueError: raise DataError("frequency must be an integer") - return self.execute_command('RESTORE', *params) - - def set(self, name, value, - ex=None, px=None, nx=False, xx=False, keepttl=False, get=False, - exat=None, pxat=None): + return self.execute_command("RESTORE", *params) + + def set( + self, + name, + value, + ex=None, + px=None, + nx=False, + xx=False, + keepttl=False, + get=False, + exat=None, + pxat=None, + ): """ Set the value at key ``name`` to ``value`` @@ -1441,7 +1488,7 @@ def set(self, name, value, pieces = [name, value] options = {} if ex is not None: - pieces.append('EX') + pieces.append("EX") if isinstance(ex, datetime.timedelta): pieces.append(int(ex.total_seconds())) elif isinstance(ex, int): @@ -1449,7 +1496,7 @@ def set(self, name, value, else: raise DataError("ex must be datetime.timedelta or int") if px is not None: - pieces.append('PX') + pieces.append("PX") if isinstance(px, datetime.timedelta): pieces.append(int(px.total_seconds() * 1000)) elif isinstance(px, int): @@ -1457,30 +1504,30 @@ def set(self, name, value, else: raise DataError("px must be datetime.timedelta or int") if exat is not None: - pieces.append('EXAT') + pieces.append("EXAT") if isinstance(exat, datetime.datetime): s = int(exat.microsecond / 1000000) exat = int(time.mktime(exat.timetuple())) + s pieces.append(exat) if pxat is not None: - pieces.append('PXAT') + pieces.append("PXAT") if isinstance(pxat, datetime.datetime): ms = int(pxat.microsecond / 1000) pxat = int(time.mktime(pxat.timetuple())) * 1000 + ms pieces.append(pxat) if keepttl: - pieces.append('KEEPTTL') + pieces.append("KEEPTTL") if nx: - pieces.append('NX') + pieces.append("NX") if xx: - pieces.append('XX') + pieces.append("XX") if get: - pieces.append('GET') + pieces.append("GET") options["get"] = True - return self.execute_command('SET', *pieces, **options) + return self.execute_command("SET", *pieces, **options) def __setitem__(self, name, value): self.set(name, value) @@ -1493,7 +1540,7 @@ def setbit(self, name, offset, value): For more information check https://redis.io/commands/setbit """ value = value and 1 or 0 - return self.execute_command('SETBIT', name, offset, value) + return self.execute_command("SETBIT", name, offset, value) def setex(self, name, time, value): """ @@ -1505,7 +1552,7 @@ def setex(self, name, time, value): """ if isinstance(time, datetime.timedelta): time = int(time.total_seconds()) - return self.execute_command('SETEX', name, time, value) + return self.execute_command("SETEX", name, time, value) def setnx(self, name, value): """ @@ -1513,7 +1560,7 @@ def setnx(self, name, value): For more information check https://redis.io/commands/setnx """ - return self.execute_command('SETNX', name, value) + return self.execute_command("SETNX", name, value) def setrange(self, name, offset, value): """ @@ -1528,10 +1575,19 @@ def setrange(self, name, offset, value): For more information check https://redis.io/commands/setrange """ - return self.execute_command('SETRANGE', name, offset, value) + return self.execute_command("SETRANGE", name, offset, value) - def stralgo(self, algo, value1, value2, specific_argument='strings', - len=False, idx=False, minmatchlen=None, withmatchlen=False): + def stralgo( + self, + algo, + value1, + value2, + specific_argument="strings", + len=False, + idx=False, + minmatchlen=None, + withmatchlen=False, + ): """ Implements complex algorithms that operate on strings. Right now the only algorithm implemented is the LCS algorithm @@ -1552,31 +1608,36 @@ def stralgo(self, algo, value1, value2, specific_argument='strings', For more information check https://redis.io/commands/stralgo """ # check validity - supported_algo = ['LCS'] + supported_algo = ["LCS"] if algo not in supported_algo: - supported_algos_str = ', '.join(supported_algo) + supported_algos_str = ", ".join(supported_algo) raise DataError(f"The supported algorithms are: {supported_algos_str}") - if specific_argument not in ['keys', 'strings']: + if specific_argument not in ["keys", "strings"]: raise DataError("specific_argument can be only keys or strings") if len and idx: raise DataError("len and idx cannot be provided together.") pieces = [algo, specific_argument.upper(), value1, value2] if len: - pieces.append(b'LEN') + pieces.append(b"LEN") if idx: - pieces.append(b'IDX') + pieces.append(b"IDX") try: int(minmatchlen) - pieces.extend([b'MINMATCHLEN', minmatchlen]) + pieces.extend([b"MINMATCHLEN", minmatchlen]) except TypeError: pass if withmatchlen: - pieces.append(b'WITHMATCHLEN') - - return self.execute_command('STRALGO', *pieces, len=len, idx=idx, - minmatchlen=minmatchlen, - withmatchlen=withmatchlen) + pieces.append(b"WITHMATCHLEN") + + return self.execute_command( + "STRALGO", + *pieces, + len=len, + idx=idx, + minmatchlen=minmatchlen, + withmatchlen=withmatchlen, + ) def strlen(self, name): """ @@ -1584,14 +1645,14 @@ def strlen(self, name): For more information check https://redis.io/commands/strlen """ - return self.execute_command('STRLEN', name) + return self.execute_command("STRLEN", name) def substr(self, name, start, end=-1): """ Return a substring of the string at key ``name``. ``start`` and ``end`` are 0-based integers specifying the portion of the string to return. """ - return self.execute_command('SUBSTR', name, start, end) + return self.execute_command("SUBSTR", name, start, end) def touch(self, *args): """ @@ -1600,7 +1661,7 @@ def touch(self, *args): For more information check https://redis.io/commands/touch """ - return self.execute_command('TOUCH', *args) + return self.execute_command("TOUCH", *args) def ttl(self, name): """ @@ -1608,7 +1669,7 @@ def ttl(self, name): For more information check https://redis.io/commands/ttl """ - return self.execute_command('TTL', name) + return self.execute_command("TTL", name) def type(self, name): """ @@ -1616,7 +1677,7 @@ def type(self, name): For more information check https://redis.io/commands/type """ - return self.execute_command('TYPE', name) + return self.execute_command("TYPE", name) def watch(self, *names): """ @@ -1624,7 +1685,7 @@ def watch(self, *names): For more information check https://redis.io/commands/type """ - warnings.warn(DeprecationWarning('Call WATCH from a Pipeline object')) + warnings.warn(DeprecationWarning("Call WATCH from a Pipeline object")) def unwatch(self): """ @@ -1632,8 +1693,7 @@ def unwatch(self): For more information check https://redis.io/commands/unwatch """ - warnings.warn( - DeprecationWarning('Call UNWATCH from a Pipeline object')) + warnings.warn(DeprecationWarning("Call UNWATCH from a Pipeline object")) def unlink(self, *names): """ @@ -1641,7 +1701,7 @@ def unlink(self, *names): For more information check https://redis.io/commands/unlink """ - return self.execute_command('UNLINK', *names) + return self.execute_command("UNLINK", *names) class ListCommands: @@ -1649,6 +1709,7 @@ class ListCommands: Redis commands for List data type. see: https://redis.io/topics/data-types#lists """ + def blpop(self, keys, timeout=0): """ LPOP a value off of the first non-empty list @@ -1666,7 +1727,7 @@ def blpop(self, keys, timeout=0): timeout = 0 keys = list_or_args(keys, None) keys.append(timeout) - return self.execute_command('BLPOP', *keys) + return self.execute_command("BLPOP", *keys) def brpop(self, keys, timeout=0): """ @@ -1685,7 +1746,7 @@ def brpop(self, keys, timeout=0): timeout = 0 keys = list_or_args(keys, None) keys.append(timeout) - return self.execute_command('BRPOP', *keys) + return self.execute_command("BRPOP", *keys) def brpoplpush(self, src, dst, timeout=0): """ @@ -1700,7 +1761,7 @@ def brpoplpush(self, src, dst, timeout=0): """ if timeout is None: timeout = 0 - return self.execute_command('BRPOPLPUSH', src, dst, timeout) + return self.execute_command("BRPOPLPUSH", src, dst, timeout) def lindex(self, name, index): """ @@ -1711,7 +1772,7 @@ def lindex(self, name, index): For more information check https://redis.io/commands/lindex """ - return self.execute_command('LINDEX', name, index) + return self.execute_command("LINDEX", name, index) def linsert(self, name, where, refvalue, value): """ @@ -1723,7 +1784,7 @@ def linsert(self, name, where, refvalue, value): For more information check https://redis.io/commands/linsert """ - return self.execute_command('LINSERT', name, where, refvalue, value) + return self.execute_command("LINSERT", name, where, refvalue, value) def llen(self, name): """ @@ -1731,7 +1792,7 @@ def llen(self, name): For more information check https://redis.io/commands/llen """ - return self.execute_command('LLEN', name) + return self.execute_command("LLEN", name) def lpop(self, name, count=None): """ @@ -1744,9 +1805,9 @@ def lpop(self, name, count=None): For more information check https://redis.io/commands/lpop """ if count is not None: - return self.execute_command('LPOP', name, count) + return self.execute_command("LPOP", name, count) else: - return self.execute_command('LPOP', name) + return self.execute_command("LPOP", name) def lpush(self, name, *values): """ @@ -1754,7 +1815,7 @@ def lpush(self, name, *values): For more information check https://redis.io/commands/lpush """ - return self.execute_command('LPUSH', name, *values) + return self.execute_command("LPUSH", name, *values) def lpushx(self, name, *values): """ @@ -1762,7 +1823,7 @@ def lpushx(self, name, *values): For more information check https://redis.io/commands/lpushx """ - return self.execute_command('LPUSHX', name, *values) + return self.execute_command("LPUSHX", name, *values) def lrange(self, name, start, end): """ @@ -1774,7 +1835,7 @@ def lrange(self, name, start, end): For more information check https://redis.io/commands/lrange """ - return self.execute_command('LRANGE', name, start, end) + return self.execute_command("LRANGE", name, start, end) def lrem(self, name, count, value): """ @@ -1788,7 +1849,7 @@ def lrem(self, name, count, value): For more information check https://redis.io/commands/lrem """ - return self.execute_command('LREM', name, count, value) + return self.execute_command("LREM", name, count, value) def lset(self, name, index, value): """ @@ -1796,7 +1857,7 @@ def lset(self, name, index, value): For more information check https://redis.io/commands/lset """ - return self.execute_command('LSET', name, index, value) + return self.execute_command("LSET", name, index, value) def ltrim(self, name, start, end): """ @@ -1808,7 +1869,7 @@ def ltrim(self, name, start, end): For more information check https://redis.io/commands/ltrim """ - return self.execute_command('LTRIM', name, start, end) + return self.execute_command("LTRIM", name, start, end) def rpop(self, name, count=None): """ @@ -1821,9 +1882,9 @@ def rpop(self, name, count=None): For more information check https://redis.io/commands/rpop """ if count is not None: - return self.execute_command('RPOP', name, count) + return self.execute_command("RPOP", name, count) else: - return self.execute_command('RPOP', name) + return self.execute_command("RPOP", name) def rpoplpush(self, src, dst): """ @@ -1832,7 +1893,7 @@ def rpoplpush(self, src, dst): For more information check https://redis.io/commands/rpoplpush """ - return self.execute_command('RPOPLPUSH', src, dst) + return self.execute_command("RPOPLPUSH", src, dst) def rpush(self, name, *values): """ @@ -1840,7 +1901,7 @@ def rpush(self, name, *values): For more information check https://redis.io/commands/rpush """ - return self.execute_command('RPUSH', name, *values) + return self.execute_command("RPUSH", name, *values) def rpushx(self, name, value): """ @@ -1848,7 +1909,7 @@ def rpushx(self, name, value): For more information check https://redis.io/commands/rpushx """ - return self.execute_command('RPUSHX', name, value) + return self.execute_command("RPUSHX", name, value) def lpos(self, name, value, rank=None, count=None, maxlen=None): """ @@ -1878,18 +1939,28 @@ def lpos(self, name, value, rank=None, count=None, maxlen=None): """ pieces = [name, value] if rank is not None: - pieces.extend(['RANK', rank]) + pieces.extend(["RANK", rank]) if count is not None: - pieces.extend(['COUNT', count]) + pieces.extend(["COUNT", count]) if maxlen is not None: - pieces.extend(['MAXLEN', maxlen]) - - return self.execute_command('LPOS', *pieces) - - def sort(self, name, start=None, num=None, by=None, get=None, - desc=False, alpha=False, store=None, groups=False): + pieces.extend(["MAXLEN", maxlen]) + + return self.execute_command("LPOS", *pieces) + + def sort( + self, + name, + start=None, + num=None, + by=None, + get=None, + desc=False, + alpha=False, + store=None, + groups=False, + ): """ Sort and return the list, set or sorted set at ``name``. @@ -1915,39 +1986,40 @@ def sort(self, name, start=None, num=None, by=None, get=None, For more information check https://redis.io/commands/sort """ - if (start is not None and num is None) or \ - (num is not None and start is None): + if (start is not None and num is None) or (num is not None and start is None): raise DataError("``start`` and ``num`` must both be specified") pieces = [name] if by is not None: - pieces.extend([b'BY', by]) + pieces.extend([b"BY", by]) if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) + pieces.extend([b"LIMIT", start, num]) if get is not None: # If get is a string assume we want to get a single value. # Otherwise assume it's an interable and we want to get multiple # values. We can't just iterate blindly because strings are # iterable. if isinstance(get, (bytes, str)): - pieces.extend([b'GET', get]) + pieces.extend([b"GET", get]) else: for g in get: - pieces.extend([b'GET', g]) + pieces.extend([b"GET", g]) if desc: - pieces.append(b'DESC') + pieces.append(b"DESC") if alpha: - pieces.append(b'ALPHA') + pieces.append(b"ALPHA") if store is not None: - pieces.extend([b'STORE', store]) + pieces.extend([b"STORE", store]) if groups: if not get or isinstance(get, (bytes, str)) or len(get) < 2: - raise DataError('when using "groups" the "get" argument ' - 'must be specified and contain at least ' - 'two keys') + raise DataError( + 'when using "groups" the "get" argument ' + "must be specified and contain at least " + "two keys" + ) - options = {'groups': len(get) if groups else None} - return self.execute_command('SORT', *pieces, **options) + options = {"groups": len(get) if groups else None} + return self.execute_command("SORT", *pieces, **options) class ScanCommands: @@ -1955,6 +2027,7 @@ class ScanCommands: Redis SCAN commands. see: https://redis.io/commands/scan """ + def scan(self, cursor=0, match=None, count=None, _type=None): """ Incrementally return lists of key names. Also return a cursor @@ -1974,12 +2047,12 @@ def scan(self, cursor=0, match=None, count=None, _type=None): """ pieces = [cursor] if match is not None: - pieces.extend([b'MATCH', match]) + pieces.extend([b"MATCH", match]) if count is not None: - pieces.extend([b'COUNT', count]) + pieces.extend([b"COUNT", count]) if _type is not None: - pieces.extend([b'TYPE', _type]) - return self.execute_command('SCAN', *pieces) + pieces.extend([b"TYPE", _type]) + return self.execute_command("SCAN", *pieces) def scan_iter(self, match=None, count=None, _type=None): """ @@ -1996,10 +2069,11 @@ def scan_iter(self, match=None, count=None, _type=None): HASH, LIST, SET, STREAM, STRING, ZSET Additionally, Redis modules can expose other types as well. """ - cursor = '0' + cursor = "0" while cursor != 0: - cursor, data = self.scan(cursor=cursor, match=match, - count=count, _type=_type) + cursor, data = self.scan( + cursor=cursor, match=match, count=count, _type=_type + ) yield from data def sscan(self, name, cursor=0, match=None, count=None): @@ -2015,10 +2089,10 @@ def sscan(self, name, cursor=0, match=None, count=None): """ pieces = [name, cursor] if match is not None: - pieces.extend([b'MATCH', match]) + pieces.extend([b"MATCH", match]) if count is not None: - pieces.extend([b'COUNT', count]) - return self.execute_command('SSCAN', *pieces) + pieces.extend([b"COUNT", count]) + return self.execute_command("SSCAN", *pieces) def sscan_iter(self, name, match=None, count=None): """ @@ -2029,10 +2103,9 @@ def sscan_iter(self, name, match=None, count=None): ``count`` allows for hint the minimum number of returns """ - cursor = '0' + cursor = "0" while cursor != 0: - cursor, data = self.sscan(name, cursor=cursor, - match=match, count=count) + cursor, data = self.sscan(name, cursor=cursor, match=match, count=count) yield from data def hscan(self, name, cursor=0, match=None, count=None): @@ -2048,10 +2121,10 @@ def hscan(self, name, cursor=0, match=None, count=None): """ pieces = [name, cursor] if match is not None: - pieces.extend([b'MATCH', match]) + pieces.extend([b"MATCH", match]) if count is not None: - pieces.extend([b'COUNT', count]) - return self.execute_command('HSCAN', *pieces) + pieces.extend([b"COUNT", count]) + return self.execute_command("HSCAN", *pieces) def hscan_iter(self, name, match=None, count=None): """ @@ -2062,14 +2135,12 @@ def hscan_iter(self, name, match=None, count=None): ``count`` allows for hint the minimum number of returns """ - cursor = '0' + cursor = "0" while cursor != 0: - cursor, data = self.hscan(name, cursor=cursor, - match=match, count=count) + cursor, data = self.hscan(name, cursor=cursor, match=match, count=count) yield from data.items() - def zscan(self, name, cursor=0, match=None, count=None, - score_cast_func=float): + def zscan(self, name, cursor=0, match=None, count=None, score_cast_func=float): """ Incrementally return lists of elements in a sorted set. Also return a cursor indicating the scan position. @@ -2084,14 +2155,13 @@ def zscan(self, name, cursor=0, match=None, count=None, """ pieces = [name, cursor] if match is not None: - pieces.extend([b'MATCH', match]) + pieces.extend([b"MATCH", match]) if count is not None: - pieces.extend([b'COUNT', count]) - options = {'score_cast_func': score_cast_func} - return self.execute_command('ZSCAN', *pieces, **options) + pieces.extend([b"COUNT", count]) + options = {"score_cast_func": score_cast_func} + return self.execute_command("ZSCAN", *pieces, **options) - def zscan_iter(self, name, match=None, count=None, - score_cast_func=float): + def zscan_iter(self, name, match=None, count=None, score_cast_func=float): """ Make an iterator using the ZSCAN command so that the client doesn't need to remember the cursor position. @@ -2102,11 +2172,15 @@ def zscan_iter(self, name, match=None, count=None, ``score_cast_func`` a callable used to cast the score return value """ - cursor = '0' + cursor = "0" while cursor != 0: - cursor, data = self.zscan(name, cursor=cursor, match=match, - count=count, - score_cast_func=score_cast_func) + cursor, data = self.zscan( + name, + cursor=cursor, + match=match, + count=count, + score_cast_func=score_cast_func, + ) yield from data @@ -2115,13 +2189,14 @@ class SetCommands: Redis commands for Set data type. see: https://redis.io/topics/data-types#sets """ + def sadd(self, name, *values): """ Add ``value(s)`` to set ``name`` For more information check https://redis.io/commands/sadd """ - return self.execute_command('SADD', name, *values) + return self.execute_command("SADD", name, *values) def scard(self, name): """ @@ -2129,7 +2204,7 @@ def scard(self, name): For more information check https://redis.io/commands/scard """ - return self.execute_command('SCARD', name) + return self.execute_command("SCARD", name) def sdiff(self, keys, *args): """ @@ -2138,7 +2213,7 @@ def sdiff(self, keys, *args): For more information check https://redis.io/commands/sdiff """ args = list_or_args(keys, args) - return self.execute_command('SDIFF', *args) + return self.execute_command("SDIFF", *args) def sdiffstore(self, dest, keys, *args): """ @@ -2148,7 +2223,7 @@ def sdiffstore(self, dest, keys, *args): For more information check https://redis.io/commands/sdiffstore """ args = list_or_args(keys, args) - return self.execute_command('SDIFFSTORE', dest, *args) + return self.execute_command("SDIFFSTORE", dest, *args) def sinter(self, keys, *args): """ @@ -2157,7 +2232,7 @@ def sinter(self, keys, *args): For more information check https://redis.io/commands/sinter """ args = list_or_args(keys, args) - return self.execute_command('SINTER', *args) + return self.execute_command("SINTER", *args) def sinterstore(self, dest, keys, *args): """ @@ -2167,7 +2242,7 @@ def sinterstore(self, dest, keys, *args): For more information check https://redis.io/commands/sinterstore """ args = list_or_args(keys, args) - return self.execute_command('SINTERSTORE', dest, *args) + return self.execute_command("SINTERSTORE", dest, *args) def sismember(self, name, value): """ @@ -2175,7 +2250,7 @@ def sismember(self, name, value): For more information check https://redis.io/commands/sismember """ - return self.execute_command('SISMEMBER', name, value) + return self.execute_command("SISMEMBER", name, value) def smembers(self, name): """ @@ -2183,7 +2258,7 @@ def smembers(self, name): For more information check https://redis.io/commands/smembers """ - return self.execute_command('SMEMBERS', name) + return self.execute_command("SMEMBERS", name) def smismember(self, name, values, *args): """ @@ -2193,7 +2268,7 @@ def smismember(self, name, values, *args): For more information check https://redis.io/commands/smismember """ args = list_or_args(values, args) - return self.execute_command('SMISMEMBER', name, *args) + return self.execute_command("SMISMEMBER", name, *args) def smove(self, src, dst, value): """ @@ -2201,7 +2276,7 @@ def smove(self, src, dst, value): For more information check https://redis.io/commands/smove """ - return self.execute_command('SMOVE', src, dst, value) + return self.execute_command("SMOVE", src, dst, value) def spop(self, name, count=None): """ @@ -2210,7 +2285,7 @@ def spop(self, name, count=None): For more information check https://redis.io/commands/spop """ args = (count is not None) and [count] or [] - return self.execute_command('SPOP', name, *args) + return self.execute_command("SPOP", name, *args) def srandmember(self, name, number=None): """ @@ -2223,7 +2298,7 @@ def srandmember(self, name, number=None): For more information check https://redis.io/commands/srandmember """ args = (number is not None) and [number] or [] - return self.execute_command('SRANDMEMBER', name, *args) + return self.execute_command("SRANDMEMBER", name, *args) def srem(self, name, *values): """ @@ -2231,7 +2306,7 @@ def srem(self, name, *values): For more information check https://redis.io/commands/srem """ - return self.execute_command('SREM', name, *values) + return self.execute_command("SREM", name, *values) def sunion(self, keys, *args): """ @@ -2240,7 +2315,7 @@ def sunion(self, keys, *args): For more information check https://redis.io/commands/sunion """ args = list_or_args(keys, args) - return self.execute_command('SUNION', *args) + return self.execute_command("SUNION", *args) def sunionstore(self, dest, keys, *args): """ @@ -2250,7 +2325,7 @@ def sunionstore(self, dest, keys, *args): For more information check https://redis.io/commands/sunionstore """ args = list_or_args(keys, args) - return self.execute_command('SUNIONSTORE', dest, *args) + return self.execute_command("SUNIONSTORE", dest, *args) class StreamCommands: @@ -2258,6 +2333,7 @@ class StreamCommands: Redis commands for Stream data type. see: https://redis.io/topics/streams-intro """ + def xack(self, name, groupname, *ids): """ Acknowledges the successful processing of one or more messages. @@ -2267,10 +2343,19 @@ def xack(self, name, groupname, *ids): For more information check https://redis.io/commands/xack """ - return self.execute_command('XACK', name, groupname, *ids) + return self.execute_command("XACK", name, groupname, *ids) - def xadd(self, name, fields, id='*', maxlen=None, approximate=True, - nomkstream=False, minid=None, limit=None): + def xadd( + self, + name, + fields, + id="*", + maxlen=None, + approximate=True, + nomkstream=False, + minid=None, + limit=None, + ): """ Add to a stream. name: name of the stream @@ -2288,34 +2373,43 @@ def xadd(self, name, fields, id='*', maxlen=None, approximate=True, """ pieces = [] if maxlen is not None and minid is not None: - raise DataError("Only one of ```maxlen``` or ```minid``` " - "may be specified") + raise DataError( + "Only one of ```maxlen``` or ```minid``` " "may be specified" + ) if maxlen is not None: if not isinstance(maxlen, int) or maxlen < 1: - raise DataError('XADD maxlen must be a positive integer') - pieces.append(b'MAXLEN') + raise DataError("XADD maxlen must be a positive integer") + pieces.append(b"MAXLEN") if approximate: - pieces.append(b'~') + pieces.append(b"~") pieces.append(str(maxlen)) if minid is not None: - pieces.append(b'MINID') + pieces.append(b"MINID") if approximate: - pieces.append(b'~') + pieces.append(b"~") pieces.append(minid) if limit is not None: - pieces.extend([b'LIMIT', limit]) + pieces.extend([b"LIMIT", limit]) if nomkstream: - pieces.append(b'NOMKSTREAM') + pieces.append(b"NOMKSTREAM") pieces.append(id) if not isinstance(fields, dict) or len(fields) == 0: - raise DataError('XADD fields must be a non-empty dict') + raise DataError("XADD fields must be a non-empty dict") for pair in fields.items(): pieces.extend(pair) - return self.execute_command('XADD', name, *pieces) - - def xautoclaim(self, name, groupname, consumername, min_idle_time, - start_id=0, count=None, justid=False): + return self.execute_command("XADD", name, *pieces) + + def xautoclaim( + self, + name, + groupname, + consumername, + min_idle_time, + start_id=0, + count=None, + justid=False, + ): """ Transfers ownership of pending stream entries that match the specified criteria. Conceptually, equivalent to calling XPENDING and then XCLAIM, @@ -2336,8 +2430,9 @@ def xautoclaim(self, name, groupname, consumername, min_idle_time, """ try: if int(min_idle_time) < 0: - raise DataError("XAUTOCLAIM min_idle_time must be a non" - "negative integer") + raise DataError( + "XAUTOCLAIM min_idle_time must be a non" "negative integer" + ) except TypeError: pass @@ -2347,18 +2442,28 @@ def xautoclaim(self, name, groupname, consumername, min_idle_time, try: if int(count) < 0: raise DataError("XPENDING count must be a integer >= 0") - pieces.extend([b'COUNT', count]) + pieces.extend([b"COUNT", count]) except TypeError: pass if justid: - pieces.append(b'JUSTID') - kwargs['parse_justid'] = True - - return self.execute_command('XAUTOCLAIM', *pieces, **kwargs) - - def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, - idle=None, time=None, retrycount=None, force=False, - justid=False): + pieces.append(b"JUSTID") + kwargs["parse_justid"] = True + + return self.execute_command("XAUTOCLAIM", *pieces, **kwargs) + + def xclaim( + self, + name, + groupname, + consumername, + min_idle_time, + message_ids, + idle=None, + time=None, + retrycount=None, + force=False, + justid=False, + ): """ Changes the ownership of a pending message. name: name of the stream. @@ -2384,11 +2489,12 @@ def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, For more information check https://redis.io/commands/xclaim """ if not isinstance(min_idle_time, int) or min_idle_time < 0: - raise DataError("XCLAIM min_idle_time must be a non negative " - "integer") + raise DataError("XCLAIM min_idle_time must be a non negative " "integer") if not isinstance(message_ids, (list, tuple)) or not message_ids: - raise DataError("XCLAIM message_ids must be a non empty list or " - "tuple of message IDs to claim") + raise DataError( + "XCLAIM message_ids must be a non empty list or " + "tuple of message IDs to claim" + ) kwargs = {} pieces = [name, groupname, consumername, str(min_idle_time)] @@ -2397,26 +2503,26 @@ def xclaim(self, name, groupname, consumername, min_idle_time, message_ids, if idle is not None: if not isinstance(idle, int): raise DataError("XCLAIM idle must be an integer") - pieces.extend((b'IDLE', str(idle))) + pieces.extend((b"IDLE", str(idle))) if time is not None: if not isinstance(time, int): raise DataError("XCLAIM time must be an integer") - pieces.extend((b'TIME', str(time))) + pieces.extend((b"TIME", str(time))) if retrycount is not None: if not isinstance(retrycount, int): raise DataError("XCLAIM retrycount must be an integer") - pieces.extend((b'RETRYCOUNT', str(retrycount))) + pieces.extend((b"RETRYCOUNT", str(retrycount))) if force: if not isinstance(force, bool): raise DataError("XCLAIM force must be a boolean") - pieces.append(b'FORCE') + pieces.append(b"FORCE") if justid: if not isinstance(justid, bool): raise DataError("XCLAIM justid must be a boolean") - pieces.append(b'JUSTID') - kwargs['parse_justid'] = True - return self.execute_command('XCLAIM', *pieces, **kwargs) + pieces.append(b"JUSTID") + kwargs["parse_justid"] = True + return self.execute_command("XCLAIM", *pieces, **kwargs) def xdel(self, name, *ids): """ @@ -2426,9 +2532,9 @@ def xdel(self, name, *ids): For more information check https://redis.io/commands/xdel """ - return self.execute_command('XDEL', name, *ids) + return self.execute_command("XDEL", name, *ids) - def xgroup_create(self, name, groupname, id='$', mkstream=False): + def xgroup_create(self, name, groupname, id="$", mkstream=False): """ Create a new consumer group associated with a stream. name: name of the stream. @@ -2437,9 +2543,9 @@ def xgroup_create(self, name, groupname, id='$', mkstream=False): For more information check https://redis.io/commands/xgroup-create """ - pieces = ['XGROUP CREATE', name, groupname, id] + pieces = ["XGROUP CREATE", name, groupname, id] if mkstream: - pieces.append(b'MKSTREAM') + pieces.append(b"MKSTREAM") return self.execute_command(*pieces) def xgroup_delconsumer(self, name, groupname, consumername): @@ -2453,8 +2559,7 @@ def xgroup_delconsumer(self, name, groupname, consumername): For more information check https://redis.io/commands/xgroup-delconsumer """ - return self.execute_command('XGROUP DELCONSUMER', name, groupname, - consumername) + return self.execute_command("XGROUP DELCONSUMER", name, groupname, consumername) def xgroup_destroy(self, name, groupname): """ @@ -2464,7 +2569,7 @@ def xgroup_destroy(self, name, groupname): For more information check https://redis.io/commands/xgroup-destroy """ - return self.execute_command('XGROUP DESTROY', name, groupname) + return self.execute_command("XGROUP DESTROY", name, groupname) def xgroup_createconsumer(self, name, groupname, consumername): """ @@ -2477,8 +2582,9 @@ def xgroup_createconsumer(self, name, groupname, consumername): See: https://redis.io/commands/xgroup-createconsumer """ - return self.execute_command('XGROUP CREATECONSUMER', name, groupname, - consumername) + return self.execute_command( + "XGROUP CREATECONSUMER", name, groupname, consumername + ) def xgroup_setid(self, name, groupname, id): """ @@ -2489,7 +2595,7 @@ def xgroup_setid(self, name, groupname, id): For more information check https://redis.io/commands/xgroup-setid """ - return self.execute_command('XGROUP SETID', name, groupname, id) + return self.execute_command("XGROUP SETID", name, groupname, id) def xinfo_consumers(self, name, groupname): """ @@ -2499,7 +2605,7 @@ def xinfo_consumers(self, name, groupname): For more information check https://redis.io/commands/xinfo-consumers """ - return self.execute_command('XINFO CONSUMERS', name, groupname) + return self.execute_command("XINFO CONSUMERS", name, groupname) def xinfo_groups(self, name): """ @@ -2508,7 +2614,7 @@ def xinfo_groups(self, name): For more information check https://redis.io/commands/xinfo-groups """ - return self.execute_command('XINFO GROUPS', name) + return self.execute_command("XINFO GROUPS", name) def xinfo_stream(self, name, full=False): """ @@ -2521,9 +2627,9 @@ def xinfo_stream(self, name, full=False): pieces = [name] options = {} if full: - pieces.append(b'FULL') - options = {'full': full} - return self.execute_command('XINFO STREAM', *pieces, **options) + pieces.append(b"FULL") + options = {"full": full} + return self.execute_command("XINFO STREAM", *pieces, **options) def xlen(self, name): """ @@ -2531,7 +2637,7 @@ def xlen(self, name): For more information check https://redis.io/commands/xlen """ - return self.execute_command('XLEN', name) + return self.execute_command("XLEN", name) def xpending(self, name, groupname): """ @@ -2541,11 +2647,18 @@ def xpending(self, name, groupname): For more information check https://redis.io/commands/xpending """ - return self.execute_command('XPENDING', name, groupname) + return self.execute_command("XPENDING", name, groupname) - def xpending_range(self, name, groupname, idle=None, - min=None, max=None, count=None, - consumername=None): + def xpending_range( + self, + name, + groupname, + idle=None, + min=None, + max=None, + count=None, + consumername=None, + ): """ Returns information about pending messages, in a range. @@ -2560,20 +2673,24 @@ def xpending_range(self, name, groupname, idle=None, """ if {min, max, count} == {None}: if idle is not None or consumername is not None: - raise DataError("if XPENDING is provided with idle time" - " or consumername, it must be provided" - " with min, max and count parameters") + raise DataError( + "if XPENDING is provided with idle time" + " or consumername, it must be provided" + " with min, max and count parameters" + ) return self.xpending(name, groupname) pieces = [name, groupname] if min is None or max is None or count is None: - raise DataError("XPENDING must be provided with min, max " - "and count parameters, or none of them.") + raise DataError( + "XPENDING must be provided with min, max " + "and count parameters, or none of them." + ) # idle try: if int(idle) < 0: raise DataError("XPENDING idle must be a integer >= 0") - pieces.extend(['IDLE', idle]) + pieces.extend(["IDLE", idle]) except TypeError: pass # count @@ -2587,9 +2704,9 @@ def xpending_range(self, name, groupname, idle=None, if consumername: pieces.append(consumername) - return self.execute_command('XPENDING', *pieces, parse_detail=True) + return self.execute_command("XPENDING", *pieces, parse_detail=True) - def xrange(self, name, min='-', max='+', count=None): + def xrange(self, name, min="-", max="+", count=None): """ Read stream values within an interval. name: name of the stream. @@ -2605,11 +2722,11 @@ def xrange(self, name, min='-', max='+', count=None): pieces = [min, max] if count is not None: if not isinstance(count, int) or count < 1: - raise DataError('XRANGE count must be a positive integer') - pieces.append(b'COUNT') + raise DataError("XRANGE count must be a positive integer") + pieces.append(b"COUNT") pieces.append(str(count)) - return self.execute_command('XRANGE', name, *pieces) + return self.execute_command("XRANGE", name, *pieces) def xread(self, streams, count=None, block=None): """ @@ -2625,24 +2742,25 @@ def xread(self, streams, count=None, block=None): pieces = [] if block is not None: if not isinstance(block, int) or block < 0: - raise DataError('XREAD block must be a non-negative integer') - pieces.append(b'BLOCK') + raise DataError("XREAD block must be a non-negative integer") + pieces.append(b"BLOCK") pieces.append(str(block)) if count is not None: if not isinstance(count, int) or count < 1: - raise DataError('XREAD count must be a positive integer') - pieces.append(b'COUNT') + raise DataError("XREAD count must be a positive integer") + pieces.append(b"COUNT") pieces.append(str(count)) if not isinstance(streams, dict) or len(streams) == 0: - raise DataError('XREAD streams must be a non empty dict') - pieces.append(b'STREAMS') + raise DataError("XREAD streams must be a non empty dict") + pieces.append(b"STREAMS") keys, values = zip(*streams.items()) pieces.extend(keys) pieces.extend(values) - return self.execute_command('XREAD', *pieces) + return self.execute_command("XREAD", *pieces) - def xreadgroup(self, groupname, consumername, streams, count=None, - block=None, noack=False): + def xreadgroup( + self, groupname, consumername, streams, count=None, block=None, noack=False + ): """ Read from a stream via a consumer group. groupname: name of the consumer group. @@ -2656,28 +2774,27 @@ def xreadgroup(self, groupname, consumername, streams, count=None, For more information check https://redis.io/commands/xreadgroup """ - pieces = [b'GROUP', groupname, consumername] + pieces = [b"GROUP", groupname, consumername] if count is not None: if not isinstance(count, int) or count < 1: raise DataError("XREADGROUP count must be a positive integer") - pieces.append(b'COUNT') + pieces.append(b"COUNT") pieces.append(str(count)) if block is not None: if not isinstance(block, int) or block < 0: - raise DataError("XREADGROUP block must be a non-negative " - "integer") - pieces.append(b'BLOCK') + raise DataError("XREADGROUP block must be a non-negative " "integer") + pieces.append(b"BLOCK") pieces.append(str(block)) if noack: - pieces.append(b'NOACK') + pieces.append(b"NOACK") if not isinstance(streams, dict) or len(streams) == 0: - raise DataError('XREADGROUP streams must be a non empty dict') - pieces.append(b'STREAMS') + raise DataError("XREADGROUP streams must be a non empty dict") + pieces.append(b"STREAMS") pieces.extend(streams.keys()) pieces.extend(streams.values()) - return self.execute_command('XREADGROUP', *pieces) + return self.execute_command("XREADGROUP", *pieces) - def xrevrange(self, name, max='+', min='-', count=None): + def xrevrange(self, name, max="+", min="-", count=None): """ Read stream values within an interval, in reverse order. name: name of the stream @@ -2693,14 +2810,13 @@ def xrevrange(self, name, max='+', min='-', count=None): pieces = [max, min] if count is not None: if not isinstance(count, int) or count < 1: - raise DataError('XREVRANGE count must be a positive integer') - pieces.append(b'COUNT') + raise DataError("XREVRANGE count must be a positive integer") + pieces.append(b"COUNT") pieces.append(str(count)) - return self.execute_command('XREVRANGE', name, *pieces) + return self.execute_command("XREVRANGE", name, *pieces) - def xtrim(self, name, maxlen=None, approximate=True, minid=None, - limit=None): + def xtrim(self, name, maxlen=None, approximate=True, minid=None, limit=None): """ Trims old messages from a stream. name: name of the stream. @@ -2715,15 +2831,14 @@ def xtrim(self, name, maxlen=None, approximate=True, minid=None, """ pieces = [] if maxlen is not None and minid is not None: - raise DataError("Only one of ``maxlen`` or ``minid`` " - "may be specified") + raise DataError("Only one of ``maxlen`` or ``minid`` " "may be specified") if maxlen is not None: - pieces.append(b'MAXLEN') + pieces.append(b"MAXLEN") if minid is not None: - pieces.append(b'MINID') + pieces.append(b"MINID") if approximate: - pieces.append(b'~') + pieces.append(b"~") if maxlen is not None: pieces.append(maxlen) if minid is not None: @@ -2732,7 +2847,7 @@ def xtrim(self, name, maxlen=None, approximate=True, minid=None, pieces.append(b"LIMIT") pieces.append(limit) - return self.execute_command('XTRIM', name, *pieces) + return self.execute_command("XTRIM", name, *pieces) class SortedSetCommands: @@ -2740,8 +2855,10 @@ class SortedSetCommands: Redis commands for Sorted Sets data type. see: https://redis.io/topics/data-types-intro#redis-sorted-sets """ - def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, - gt=None, lt=None): + + def zadd( + self, name, mapping, nx=False, xx=False, ch=False, incr=False, gt=None, lt=None + ): """ Set any number of element-name, score pairs to the key ``name``. Pairs are specified as a dict of element-names keys to score values. @@ -2780,30 +2897,32 @@ def zadd(self, name, mapping, nx=False, xx=False, ch=False, incr=False, if nx and xx: raise DataError("ZADD allows either 'nx' or 'xx', not both") if incr and len(mapping) != 1: - raise DataError("ZADD option 'incr' only works when passing a " - "single element/score pair") + raise DataError( + "ZADD option 'incr' only works when passing a " + "single element/score pair" + ) if nx is True and (gt is not None or lt is not None): raise DataError("Only one of 'nx', 'lt', or 'gr' may be defined.") pieces = [] options = {} if nx: - pieces.append(b'NX') + pieces.append(b"NX") if xx: - pieces.append(b'XX') + pieces.append(b"XX") if ch: - pieces.append(b'CH') + pieces.append(b"CH") if incr: - pieces.append(b'INCR') - options['as_score'] = True + pieces.append(b"INCR") + options["as_score"] = True if gt: - pieces.append(b'GT') + pieces.append(b"GT") if lt: - pieces.append(b'LT') + pieces.append(b"LT") for pair in mapping.items(): pieces.append(pair[1]) pieces.append(pair[0]) - return self.execute_command('ZADD', name, *pieces, **options) + return self.execute_command("ZADD", name, *pieces, **options) def zcard(self, name): """ @@ -2811,7 +2930,7 @@ def zcard(self, name): For more information check https://redis.io/commands/zcard """ - return self.execute_command('ZCARD', name) + return self.execute_command("ZCARD", name) def zcount(self, name, min, max): """ @@ -2820,7 +2939,7 @@ def zcount(self, name, min, max): For more information check https://redis.io/commands/zcount """ - return self.execute_command('ZCOUNT', name, min, max) + return self.execute_command("ZCOUNT", name, min, max) def zdiff(self, keys, withscores=False): """ @@ -2850,7 +2969,7 @@ def zincrby(self, name, amount, value): For more information check https://redis.io/commands/zincrby """ - return self.execute_command('ZINCRBY', name, amount, value) + return self.execute_command("ZINCRBY", name, amount, value) def zinter(self, keys, aggregate=None, withscores=False): """ @@ -2864,8 +2983,7 @@ def zinter(self, keys, aggregate=None, withscores=False): For more information check https://redis.io/commands/zinter """ - return self._zaggregate('ZINTER', None, keys, aggregate, - withscores=withscores) + return self._zaggregate("ZINTER", None, keys, aggregate, withscores=withscores) def zinterstore(self, dest, keys, aggregate=None): """ @@ -2879,7 +2997,7 @@ def zinterstore(self, dest, keys, aggregate=None): For more information check https://redis.io/commands/zinterstore """ - return self._zaggregate('ZINTERSTORE', dest, keys, aggregate) + return self._zaggregate("ZINTERSTORE", dest, keys, aggregate) def zlexcount(self, name, min, max): """ @@ -2888,7 +3006,7 @@ def zlexcount(self, name, min, max): For more information check https://redis.io/commands/zlexcount """ - return self.execute_command('ZLEXCOUNT', name, min, max) + return self.execute_command("ZLEXCOUNT", name, min, max) def zpopmax(self, name, count=None): """ @@ -2898,10 +3016,8 @@ def zpopmax(self, name, count=None): For more information check https://redis.io/commands/zpopmax """ args = (count is not None) and [count] or [] - options = { - 'withscores': True - } - return self.execute_command('ZPOPMAX', name, *args, **options) + options = {"withscores": True} + return self.execute_command("ZPOPMAX", name, *args, **options) def zpopmin(self, name, count=None): """ @@ -2911,10 +3027,8 @@ def zpopmin(self, name, count=None): For more information check https://redis.io/commands/zpopmin """ args = (count is not None) and [count] or [] - options = { - 'withscores': True - } - return self.execute_command('ZPOPMIN', name, *args, **options) + options = {"withscores": True} + return self.execute_command("ZPOPMIN", name, *args, **options) def zrandmember(self, key, count=None, withscores=False): """ @@ -2957,7 +3071,7 @@ def bzpopmax(self, keys, timeout=0): timeout = 0 keys = list_or_args(keys, None) keys.append(timeout) - return self.execute_command('BZPOPMAX', *keys) + return self.execute_command("BZPOPMAX", *keys) def bzpopmin(self, keys, timeout=0): """ @@ -2976,43 +3090,63 @@ def bzpopmin(self, keys, timeout=0): timeout = 0 keys = list_or_args(keys, None) keys.append(timeout) - return self.execute_command('BZPOPMIN', *keys) - - def _zrange(self, command, dest, name, start, end, desc=False, - byscore=False, bylex=False, withscores=False, - score_cast_func=float, offset=None, num=None): + return self.execute_command("BZPOPMIN", *keys) + + def _zrange( + self, + command, + dest, + name, + start, + end, + desc=False, + byscore=False, + bylex=False, + withscores=False, + score_cast_func=float, + offset=None, + num=None, + ): if byscore and bylex: - raise DataError("``byscore`` and ``bylex`` can not be " - "specified together.") - if (offset is not None and num is None) or \ - (num is not None and offset is None): + raise DataError( + "``byscore`` and ``bylex`` can not be " "specified together." + ) + if (offset is not None and num is None) or (num is not None and offset is None): raise DataError("``offset`` and ``num`` must both be specified.") if bylex and withscores: - raise DataError("``withscores`` not supported in combination " - "with ``bylex``.") + raise DataError( + "``withscores`` not supported in combination " "with ``bylex``." + ) pieces = [command] if dest: pieces.append(dest) pieces.extend([name, start, end]) if byscore: - pieces.append('BYSCORE') + pieces.append("BYSCORE") if bylex: - pieces.append('BYLEX') + pieces.append("BYLEX") if desc: - pieces.append('REV') + pieces.append("REV") if offset is not None and num is not None: - pieces.extend(['LIMIT', offset, num]) + pieces.extend(["LIMIT", offset, num]) if withscores: - pieces.append('WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } + pieces.append("WITHSCORES") + options = {"withscores": withscores, "score_cast_func": score_cast_func} return self.execute_command(*pieces, **options) - def zrange(self, name, start, end, desc=False, withscores=False, - score_cast_func=float, byscore=False, bylex=False, - offset=None, num=None): + def zrange( + self, + name, + start, + end, + desc=False, + withscores=False, + score_cast_func=float, + byscore=False, + bylex=False, + offset=None, + num=None, + ): """ Return a range of values from sorted set ``name`` between ``start`` and ``end`` sorted in ascending order. @@ -3043,16 +3177,25 @@ def zrange(self, name, start, end, desc=False, withscores=False, """ # Need to support ``desc`` also when using old redis version # because it was supported in 3.5.3 (of redis-py) - if not byscore and not bylex and (offset is None and num is None) \ - and desc: - return self.zrevrange(name, start, end, withscores, - score_cast_func) - - return self._zrange('ZRANGE', None, name, start, end, desc, byscore, - bylex, withscores, score_cast_func, offset, num) + if not byscore and not bylex and (offset is None and num is None) and desc: + return self.zrevrange(name, start, end, withscores, score_cast_func) + + return self._zrange( + "ZRANGE", + None, + name, + start, + end, + desc, + byscore, + bylex, + withscores, + score_cast_func, + offset, + num, + ) - def zrevrange(self, name, start, end, withscores=False, - score_cast_func=float): + def zrevrange(self, name, start, end, withscores=False, score_cast_func=float): """ Return a range of values from sorted set ``name`` between ``start`` and ``end`` sorted in descending order. @@ -3066,18 +3209,24 @@ def zrevrange(self, name, start, end, withscores=False, For more information check https://redis.io/commands/zrevrange """ - pieces = ['ZREVRANGE', name, start, end] + pieces = ["ZREVRANGE", name, start, end] if withscores: - pieces.append(b'WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } + pieces.append(b"WITHSCORES") + options = {"withscores": withscores, "score_cast_func": score_cast_func} return self.execute_command(*pieces, **options) - def zrangestore(self, dest, name, start, end, - byscore=False, bylex=False, desc=False, - offset=None, num=None): + def zrangestore( + self, + dest, + name, + start, + end, + byscore=False, + bylex=False, + desc=False, + offset=None, + num=None, + ): """ Stores in ``dest`` the result of a range of values from sorted set ``name`` between ``start`` and ``end`` sorted in ascending order. @@ -3101,8 +3250,20 @@ def zrangestore(self, dest, name, start, end, For more information check https://redis.io/commands/zrangestore """ - return self._zrange('ZRANGESTORE', dest, name, start, end, desc, - byscore, bylex, False, None, offset, num) + return self._zrange( + "ZRANGESTORE", + dest, + name, + start, + end, + desc, + byscore, + bylex, + False, + None, + offset, + num, + ) def zrangebylex(self, name, min, max, start=None, num=None): """ @@ -3114,12 +3275,11 @@ def zrangebylex(self, name, min, max, start=None, num=None): For more information check https://redis.io/commands/zrangebylex """ - if (start is not None and num is None) or \ - (num is not None and start is None): + if (start is not None and num is None) or (num is not None and start is None): raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZRANGEBYLEX', name, min, max] + pieces = ["ZRANGEBYLEX", name, min, max] if start is not None and num is not None: - pieces.extend([b'LIMIT', start, num]) + pieces.extend([b"LIMIT", start, num]) return self.execute_command(*pieces) def zrevrangebylex(self, name, max, min, start=None, num=None): @@ -3132,16 +3292,23 @@ def zrevrangebylex(self, name, max, min, start=None, num=None): For more information check https://redis.io/commands/zrevrangebylex """ - if (start is not None and num is None) or \ - (num is not None and start is None): + if (start is not None and num is None) or (num is not None and start is None): raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZREVRANGEBYLEX', name, max, min] + pieces = ["ZREVRANGEBYLEX", name, max, min] if start is not None and num is not None: - pieces.extend(['LIMIT', start, num]) + pieces.extend(["LIMIT", start, num]) return self.execute_command(*pieces) - def zrangebyscore(self, name, min, max, start=None, num=None, - withscores=False, score_cast_func=float): + def zrangebyscore( + self, + name, + min, + max, + start=None, + num=None, + withscores=False, + score_cast_func=float, + ): """ Return a range of values from the sorted set ``name`` with scores between ``min`` and ``max``. @@ -3156,22 +3323,26 @@ def zrangebyscore(self, name, min, max, start=None, num=None, For more information check https://redis.io/commands/zrangebyscore """ - if (start is not None and num is None) or \ - (num is not None and start is None): + if (start is not None and num is None) or (num is not None and start is None): raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZRANGEBYSCORE', name, min, max] + pieces = ["ZRANGEBYSCORE", name, min, max] if start is not None and num is not None: - pieces.extend(['LIMIT', start, num]) + pieces.extend(["LIMIT", start, num]) if withscores: - pieces.append('WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } + pieces.append("WITHSCORES") + options = {"withscores": withscores, "score_cast_func": score_cast_func} return self.execute_command(*pieces, **options) - def zrevrangebyscore(self, name, max, min, start=None, num=None, - withscores=False, score_cast_func=float): + def zrevrangebyscore( + self, + name, + max, + min, + start=None, + num=None, + withscores=False, + score_cast_func=float, + ): """ Return a range of values from the sorted set ``name`` with scores between ``min`` and ``max`` in descending order. @@ -3186,18 +3357,14 @@ def zrevrangebyscore(self, name, max, min, start=None, num=None, For more information check https://redis.io/commands/zrevrangebyscore """ - if (start is not None and num is None) or \ - (num is not None and start is None): + if (start is not None and num is None) or (num is not None and start is None): raise DataError("``start`` and ``num`` must both be specified") - pieces = ['ZREVRANGEBYSCORE', name, max, min] + pieces = ["ZREVRANGEBYSCORE", name, max, min] if start is not None and num is not None: - pieces.extend(['LIMIT', start, num]) + pieces.extend(["LIMIT", start, num]) if withscores: - pieces.append('WITHSCORES') - options = { - 'withscores': withscores, - 'score_cast_func': score_cast_func - } + pieces.append("WITHSCORES") + options = {"withscores": withscores, "score_cast_func": score_cast_func} return self.execute_command(*pieces, **options) def zrank(self, name, value): @@ -3207,7 +3374,7 @@ def zrank(self, name, value): For more information check https://redis.io/commands/zrank """ - return self.execute_command('ZRANK', name, value) + return self.execute_command("ZRANK", name, value) def zrem(self, name, *values): """ @@ -3215,7 +3382,7 @@ def zrem(self, name, *values): For more information check https://redis.io/commands/zrem """ - return self.execute_command('ZREM', name, *values) + return self.execute_command("ZREM", name, *values) def zremrangebylex(self, name, min, max): """ @@ -3226,7 +3393,7 @@ def zremrangebylex(self, name, min, max): For more information check https://redis.io/commands/zremrangebylex """ - return self.execute_command('ZREMRANGEBYLEX', name, min, max) + return self.execute_command("ZREMRANGEBYLEX", name, min, max) def zremrangebyrank(self, name, min, max): """ @@ -3237,7 +3404,7 @@ def zremrangebyrank(self, name, min, max): For more information check https://redis.io/commands/zremrangebyrank """ - return self.execute_command('ZREMRANGEBYRANK', name, min, max) + return self.execute_command("ZREMRANGEBYRANK", name, min, max) def zremrangebyscore(self, name, min, max): """ @@ -3246,7 +3413,7 @@ def zremrangebyscore(self, name, min, max): For more information check https://redis.io/commands/zremrangebyscore """ - return self.execute_command('ZREMRANGEBYSCORE', name, min, max) + return self.execute_command("ZREMRANGEBYSCORE", name, min, max) def zrevrank(self, name, value): """ @@ -3255,7 +3422,7 @@ def zrevrank(self, name, value): For more information check https://redis.io/commands/zrevrank """ - return self.execute_command('ZREVRANK', name, value) + return self.execute_command("ZREVRANK", name, value) def zscore(self, name, value): """ @@ -3263,7 +3430,7 @@ def zscore(self, name, value): For more information check https://redis.io/commands/zscore """ - return self.execute_command('ZSCORE', name, value) + return self.execute_command("ZSCORE", name, value) def zunion(self, keys, aggregate=None, withscores=False): """ @@ -3274,8 +3441,7 @@ def zunion(self, keys, aggregate=None, withscores=False): For more information check https://redis.io/commands/zunion """ - return self._zaggregate('ZUNION', None, keys, aggregate, - withscores=withscores) + return self._zaggregate("ZUNION", None, keys, aggregate, withscores=withscores) def zunionstore(self, dest, keys, aggregate=None): """ @@ -3285,7 +3451,7 @@ def zunionstore(self, dest, keys, aggregate=None): For more information check https://redis.io/commands/zunionstore """ - return self._zaggregate('ZUNIONSTORE', dest, keys, aggregate) + return self._zaggregate("ZUNIONSTORE", dest, keys, aggregate) def zmscore(self, key, members): """ @@ -3299,12 +3465,11 @@ def zmscore(self, key, members): For more information check https://redis.io/commands/zmscore """ if not members: - raise DataError('ZMSCORE members must be a non-empty list') + raise DataError("ZMSCORE members must be a non-empty list") pieces = [key] + members - return self.execute_command('ZMSCORE', *pieces) + return self.execute_command("ZMSCORE", *pieces) - def _zaggregate(self, command, dest, keys, aggregate=None, - **options): + def _zaggregate(self, command, dest, keys, aggregate=None, **options): pieces = [command] if dest is not None: pieces.append(dest) @@ -3315,16 +3480,16 @@ def _zaggregate(self, command, dest, keys, aggregate=None, weights = None pieces.extend(keys) if weights: - pieces.append(b'WEIGHTS') + pieces.append(b"WEIGHTS") pieces.extend(weights) if aggregate: - if aggregate.upper() in ['SUM', 'MIN', 'MAX']: - pieces.append(b'AGGREGATE') + if aggregate.upper() in ["SUM", "MIN", "MAX"]: + pieces.append(b"AGGREGATE") pieces.append(aggregate) else: raise DataError("aggregate can be sum, min or max.") - if options.get('withscores', False): - pieces.append(b'WITHSCORES') + if options.get("withscores", False): + pieces.append(b"WITHSCORES") return self.execute_command(*pieces, **options) @@ -3333,13 +3498,14 @@ class HyperlogCommands: Redis commands of HyperLogLogs data type. see: https://redis.io/topics/data-types-intro#hyperloglogs """ + def pfadd(self, name, *values): """ Adds the specified elements to the specified HyperLogLog. For more information check https://redis.io/commands/pfadd """ - return self.execute_command('PFADD', name, *values) + return self.execute_command("PFADD", name, *values) def pfcount(self, *sources): """ @@ -3348,7 +3514,7 @@ def pfcount(self, *sources): For more information check https://redis.io/commands/pfcount """ - return self.execute_command('PFCOUNT', *sources) + return self.execute_command("PFCOUNT", *sources) def pfmerge(self, dest, *sources): """ @@ -3356,7 +3522,7 @@ def pfmerge(self, dest, *sources): For more information check https://redis.io/commands/pfmerge """ - return self.execute_command('PFMERGE', dest, *sources) + return self.execute_command("PFMERGE", dest, *sources) class HashCommands: @@ -3364,13 +3530,14 @@ class HashCommands: Redis commands for Hash data type. see: https://redis.io/topics/data-types-intro#redis-hashes """ + def hdel(self, name, *keys): """ Delete ``keys`` from hash ``name`` For more information check https://redis.io/commands/hdel """ - return self.execute_command('HDEL', name, *keys) + return self.execute_command("HDEL", name, *keys) def hexists(self, name, key): """ @@ -3378,7 +3545,7 @@ def hexists(self, name, key): For more information check https://redis.io/commands/hexists """ - return self.execute_command('HEXISTS', name, key) + return self.execute_command("HEXISTS", name, key) def hget(self, name, key): """ @@ -3386,7 +3553,7 @@ def hget(self, name, key): For more information check https://redis.io/commands/hget """ - return self.execute_command('HGET', name, key) + return self.execute_command("HGET", name, key) def hgetall(self, name): """ @@ -3394,7 +3561,7 @@ def hgetall(self, name): For more information check https://redis.io/commands/hgetall """ - return self.execute_command('HGETALL', name) + return self.execute_command("HGETALL", name) def hincrby(self, name, key, amount=1): """ @@ -3402,7 +3569,7 @@ def hincrby(self, name, key, amount=1): For more information check https://redis.io/commands/hincrby """ - return self.execute_command('HINCRBY', name, key, amount) + return self.execute_command("HINCRBY", name, key, amount) def hincrbyfloat(self, name, key, amount=1.0): """ @@ -3410,7 +3577,7 @@ def hincrbyfloat(self, name, key, amount=1.0): For more information check https://redis.io/commands/hincrbyfloat """ - return self.execute_command('HINCRBYFLOAT', name, key, amount) + return self.execute_command("HINCRBYFLOAT", name, key, amount) def hkeys(self, name): """ @@ -3418,7 +3585,7 @@ def hkeys(self, name): For more information check https://redis.io/commands/hkeys """ - return self.execute_command('HKEYS', name) + return self.execute_command("HKEYS", name) def hlen(self, name): """ @@ -3426,7 +3593,7 @@ def hlen(self, name): For more information check https://redis.io/commands/hlen """ - return self.execute_command('HLEN', name) + return self.execute_command("HLEN", name) def hset(self, name, key=None, value=None, mapping=None): """ @@ -3446,7 +3613,7 @@ def hset(self, name, key=None, value=None, mapping=None): for pair in mapping.items(): items.extend(pair) - return self.execute_command('HSET', name, *items) + return self.execute_command("HSET", name, *items) def hsetnx(self, name, key, value): """ @@ -3455,7 +3622,7 @@ def hsetnx(self, name, key, value): For more information check https://redis.io/commands/hsetnx """ - return self.execute_command('HSETNX', name, key, value) + return self.execute_command("HSETNX", name, key, value) def hmset(self, name, mapping): """ @@ -3465,8 +3632,8 @@ def hmset(self, name, mapping): For more information check https://redis.io/commands/hmset """ warnings.warn( - f'{self.__class__.__name__}.hmset() is deprecated. ' - f'Use {self.__class__.__name__}.hset() instead.', + f"{self.__class__.__name__}.hmset() is deprecated. " + f"Use {self.__class__.__name__}.hset() instead.", DeprecationWarning, stacklevel=2, ) @@ -3475,7 +3642,7 @@ def hmset(self, name, mapping): items = [] for pair in mapping.items(): items.extend(pair) - return self.execute_command('HMSET', name, *items) + return self.execute_command("HMSET", name, *items) def hmget(self, name, keys, *args): """ @@ -3484,7 +3651,7 @@ def hmget(self, name, keys, *args): For more information check https://redis.io/commands/hmget """ args = list_or_args(keys, args) - return self.execute_command('HMGET', name, *args) + return self.execute_command("HMGET", name, *args) def hvals(self, name): """ @@ -3492,7 +3659,7 @@ def hvals(self, name): For more information check https://redis.io/commands/hvals """ - return self.execute_command('HVALS', name) + return self.execute_command("HVALS", name) def hstrlen(self, name, key): """ @@ -3501,7 +3668,7 @@ def hstrlen(self, name, key): For more information check https://redis.io/commands/hstrlen """ - return self.execute_command('HSTRLEN', name, key) + return self.execute_command("HSTRLEN", name, key) class PubSubCommands: @@ -3509,6 +3676,7 @@ class PubSubCommands: Redis PubSub commands. see https://redis.io/topics/pubsub """ + def publish(self, channel, message): """ Publish ``message`` on ``channel``. @@ -3516,15 +3684,15 @@ def publish(self, channel, message): For more information check https://redis.io/commands/publish """ - return self.execute_command('PUBLISH', channel, message) + return self.execute_command("PUBLISH", channel, message) - def pubsub_channels(self, pattern='*'): + def pubsub_channels(self, pattern="*"): """ Return a list of channels that have at least one subscriber For more information check https://redis.io/commands/pubsub-channels """ - return self.execute_command('PUBSUB CHANNELS', pattern) + return self.execute_command("PUBSUB CHANNELS", pattern) def pubsub_numpat(self): """ @@ -3532,7 +3700,7 @@ def pubsub_numpat(self): For more information check https://redis.io/commands/pubsub-numpat """ - return self.execute_command('PUBSUB NUMPAT') + return self.execute_command("PUBSUB NUMPAT") def pubsub_numsub(self, *args): """ @@ -3541,7 +3709,7 @@ def pubsub_numsub(self, *args): For more information check https://redis.io/commands/pubsub-numsub """ - return self.execute_command('PUBSUB NUMSUB', *args) + return self.execute_command("PUBSUB NUMSUB", *args) class ScriptCommands: @@ -3549,6 +3717,7 @@ class ScriptCommands: Redis Lua script commands. see: https://redis.com/ebook/part-3-next-steps/chapter-11-scripting-redis-with-lua/ """ + def eval(self, script, numkeys, *keys_and_args): """ Execute the Lua ``script``, specifying the ``numkeys`` the script @@ -3560,7 +3729,7 @@ def eval(self, script, numkeys, *keys_and_args): For more information check https://redis.io/commands/eval """ - return self.execute_command('EVAL', script, numkeys, *keys_and_args) + return self.execute_command("EVAL", script, numkeys, *keys_and_args) def evalsha(self, sha, numkeys, *keys_and_args): """ @@ -3574,7 +3743,7 @@ def evalsha(self, sha, numkeys, *keys_and_args): For more information check https://redis.io/commands/evalsha """ - return self.execute_command('EVALSHA', sha, numkeys, *keys_and_args) + return self.execute_command("EVALSHA", sha, numkeys, *keys_and_args) def script_exists(self, *args): """ @@ -3584,7 +3753,7 @@ def script_exists(self, *args): For more information check https://redis.io/commands/script-exists """ - return self.execute_command('SCRIPT EXISTS', *args) + return self.execute_command("SCRIPT EXISTS", *args) def script_debug(self, *args): raise NotImplementedError( @@ -3600,14 +3769,16 @@ def script_flush(self, sync_type=None): # Redis pre 6 had no sync_type. if sync_type not in ["SYNC", "ASYNC", None]: - raise DataError("SCRIPT FLUSH defaults to SYNC in redis > 6.2, or " - "accepts SYNC/ASYNC. For older versions, " - "of redis leave as None.") + raise DataError( + "SCRIPT FLUSH defaults to SYNC in redis > 6.2, or " + "accepts SYNC/ASYNC. For older versions, " + "of redis leave as None." + ) if sync_type is None: pieces = [] else: pieces = [sync_type] - return self.execute_command('SCRIPT FLUSH', *pieces) + return self.execute_command("SCRIPT FLUSH", *pieces) def script_kill(self): """ @@ -3615,7 +3786,7 @@ def script_kill(self): For more information check https://redis.io/commands/script-kill """ - return self.execute_command('SCRIPT KILL') + return self.execute_command("SCRIPT KILL") def script_load(self, script): """ @@ -3623,7 +3794,7 @@ def script_load(self, script): For more information check https://redis.io/commands/script-load """ - return self.execute_command('SCRIPT LOAD', script) + return self.execute_command("SCRIPT LOAD", script) def register_script(self, script): """ @@ -3640,6 +3811,7 @@ class GeoCommands: Redis Geospatial commands. see: https://redis.com/redis-best-practices/indexing-patterns/geospatial/ """ + def geoadd(self, name, values, nx=False, xx=False, ch=False): """ Add the specified geospatial items to the specified key identified @@ -3664,17 +3836,16 @@ def geoadd(self, name, values, nx=False, xx=False, ch=False): if nx and xx: raise DataError("GEOADD allows either 'nx' or 'xx', not both") if len(values) % 3 != 0: - raise DataError("GEOADD requires places with lon, lat and name" - " values") + raise DataError("GEOADD requires places with lon, lat and name" " values") pieces = [name] if nx: - pieces.append('NX') + pieces.append("NX") if xx: - pieces.append('XX') + pieces.append("XX") if ch: - pieces.append('CH') + pieces.append("CH") pieces.extend(values) - return self.execute_command('GEOADD', *pieces) + return self.execute_command("GEOADD", *pieces) def geodist(self, name, place1, place2, unit=None): """ @@ -3686,11 +3857,11 @@ def geodist(self, name, place1, place2, unit=None): For more information check https://redis.io/commands/geodist """ pieces = [name, place1, place2] - if unit and unit not in ('m', 'km', 'mi', 'ft'): + if unit and unit not in ("m", "km", "mi", "ft"): raise DataError("GEODIST invalid unit") elif unit: pieces.append(unit) - return self.execute_command('GEODIST', *pieces) + return self.execute_command("GEODIST", *pieces) def geohash(self, name, *values): """ @@ -3699,7 +3870,7 @@ def geohash(self, name, *values): For more information check https://redis.io/commands/geohash """ - return self.execute_command('GEOHASH', name, *values) + return self.execute_command("GEOHASH", name, *values) def geopos(self, name, *values): """ @@ -3709,11 +3880,24 @@ def geopos(self, name, *values): For more information check https://redis.io/commands/geopos """ - return self.execute_command('GEOPOS', name, *values) - - def georadius(self, name, longitude, latitude, radius, unit=None, - withdist=False, withcoord=False, withhash=False, count=None, - sort=None, store=None, store_dist=None, any=False): + return self.execute_command("GEOPOS", name, *values) + + def georadius( + self, + name, + longitude, + latitude, + radius, + unit=None, + withdist=False, + withcoord=False, + withhash=False, + count=None, + sort=None, + store=None, + store_dist=None, + any=False, + ): """ Return the members of the specified key identified by the ``name`` argument which are within the borders of the area specified @@ -3744,17 +3928,38 @@ def georadius(self, name, longitude, latitude, radius, unit=None, For more information check https://redis.io/commands/georadius """ - return self._georadiusgeneric('GEORADIUS', - name, longitude, latitude, radius, - unit=unit, withdist=withdist, - withcoord=withcoord, withhash=withhash, - count=count, sort=sort, store=store, - store_dist=store_dist, any=any) + return self._georadiusgeneric( + "GEORADIUS", + name, + longitude, + latitude, + radius, + unit=unit, + withdist=withdist, + withcoord=withcoord, + withhash=withhash, + count=count, + sort=sort, + store=store, + store_dist=store_dist, + any=any, + ) - def georadiusbymember(self, name, member, radius, unit=None, - withdist=False, withcoord=False, withhash=False, - count=None, sort=None, store=None, store_dist=None, - any=False): + def georadiusbymember( + self, + name, + member, + radius, + unit=None, + withdist=False, + withcoord=False, + withhash=False, + count=None, + sort=None, + store=None, + store_dist=None, + any=False, + ): """ This command is exactly like ``georadius`` with the sole difference that instead of taking, as the center of the area to query, a longitude @@ -3763,61 +3968,85 @@ def georadiusbymember(self, name, member, radius, unit=None, For more information check https://redis.io/commands/georadiusbymember """ - return self._georadiusgeneric('GEORADIUSBYMEMBER', - name, member, radius, unit=unit, - withdist=withdist, withcoord=withcoord, - withhash=withhash, count=count, - sort=sort, store=store, - store_dist=store_dist, any=any) + return self._georadiusgeneric( + "GEORADIUSBYMEMBER", + name, + member, + radius, + unit=unit, + withdist=withdist, + withcoord=withcoord, + withhash=withhash, + count=count, + sort=sort, + store=store, + store_dist=store_dist, + any=any, + ) def _georadiusgeneric(self, command, *args, **kwargs): pieces = list(args) - if kwargs['unit'] and kwargs['unit'] not in ('m', 'km', 'mi', 'ft'): + if kwargs["unit"] and kwargs["unit"] not in ("m", "km", "mi", "ft"): raise DataError("GEORADIUS invalid unit") - elif kwargs['unit']: - pieces.append(kwargs['unit']) + elif kwargs["unit"]: + pieces.append(kwargs["unit"]) else: - pieces.append('m',) + pieces.append( + "m", + ) - if kwargs['any'] and kwargs['count'] is None: + if kwargs["any"] and kwargs["count"] is None: raise DataError("``any`` can't be provided without ``count``") for arg_name, byte_repr in ( - ('withdist', 'WITHDIST'), - ('withcoord', 'WITHCOORD'), - ('withhash', 'WITHHASH')): + ("withdist", "WITHDIST"), + ("withcoord", "WITHCOORD"), + ("withhash", "WITHHASH"), + ): if kwargs[arg_name]: pieces.append(byte_repr) - if kwargs['count'] is not None: - pieces.extend(['COUNT', kwargs['count']]) - if kwargs['any']: - pieces.append('ANY') + if kwargs["count"] is not None: + pieces.extend(["COUNT", kwargs["count"]]) + if kwargs["any"]: + pieces.append("ANY") - if kwargs['sort']: - if kwargs['sort'] == 'ASC': - pieces.append('ASC') - elif kwargs['sort'] == 'DESC': - pieces.append('DESC') + if kwargs["sort"]: + if kwargs["sort"] == "ASC": + pieces.append("ASC") + elif kwargs["sort"] == "DESC": + pieces.append("DESC") else: raise DataError("GEORADIUS invalid sort") - if kwargs['store'] and kwargs['store_dist']: - raise DataError("GEORADIUS store and store_dist cant be set" - " together") + if kwargs["store"] and kwargs["store_dist"]: + raise DataError("GEORADIUS store and store_dist cant be set" " together") - if kwargs['store']: - pieces.extend([b'STORE', kwargs['store']]) + if kwargs["store"]: + pieces.extend([b"STORE", kwargs["store"]]) - if kwargs['store_dist']: - pieces.extend([b'STOREDIST', kwargs['store_dist']]) + if kwargs["store_dist"]: + pieces.extend([b"STOREDIST", kwargs["store_dist"]]) return self.execute_command(command, *pieces, **kwargs) - def geosearch(self, name, member=None, longitude=None, latitude=None, - unit='m', radius=None, width=None, height=None, sort=None, - count=None, any=False, withcoord=False, - withdist=False, withhash=False): + def geosearch( + self, + name, + member=None, + longitude=None, + latitude=None, + unit="m", + radius=None, + width=None, + height=None, + sort=None, + count=None, + any=False, + withcoord=False, + withdist=False, + withhash=False, + ): """ Return the members of specified key identified by the ``name`` argument, which are within the borders of the @@ -3853,19 +4082,42 @@ def geosearch(self, name, member=None, longitude=None, latitude=None, For more information check https://redis.io/commands/geosearch """ - return self._geosearchgeneric('GEOSEARCH', - name, member=member, longitude=longitude, - latitude=latitude, unit=unit, - radius=radius, width=width, - height=height, sort=sort, count=count, - any=any, withcoord=withcoord, - withdist=withdist, withhash=withhash, - store=None, store_dist=None) + return self._geosearchgeneric( + "GEOSEARCH", + name, + member=member, + longitude=longitude, + latitude=latitude, + unit=unit, + radius=radius, + width=width, + height=height, + sort=sort, + count=count, + any=any, + withcoord=withcoord, + withdist=withdist, + withhash=withhash, + store=None, + store_dist=None, + ) - def geosearchstore(self, dest, name, member=None, longitude=None, - latitude=None, unit='m', radius=None, width=None, - height=None, sort=None, count=None, any=False, - storedist=False): + def geosearchstore( + self, + dest, + name, + member=None, + longitude=None, + latitude=None, + unit="m", + radius=None, + width=None, + height=None, + sort=None, + count=None, + any=False, + storedist=False, + ): """ This command is like GEOSEARCH, but stores the result in ``dest``. By default, it stores the results in the destination @@ -3876,74 +4128,86 @@ def geosearchstore(self, dest, name, member=None, longitude=None, For more information check https://redis.io/commands/geosearchstore """ - return self._geosearchgeneric('GEOSEARCHSTORE', - dest, name, member=member, - longitude=longitude, latitude=latitude, - unit=unit, radius=radius, width=width, - height=height, sort=sort, count=count, - any=any, withcoord=None, - withdist=None, withhash=None, - store=None, store_dist=storedist) + return self._geosearchgeneric( + "GEOSEARCHSTORE", + dest, + name, + member=member, + longitude=longitude, + latitude=latitude, + unit=unit, + radius=radius, + width=width, + height=height, + sort=sort, + count=count, + any=any, + withcoord=None, + withdist=None, + withhash=None, + store=None, + store_dist=storedist, + ) def _geosearchgeneric(self, command, *args, **kwargs): pieces = list(args) # FROMMEMBER or FROMLONLAT - if kwargs['member'] is None: - if kwargs['longitude'] is None or kwargs['latitude'] is None: - raise DataError("GEOSEARCH must have member or" - " longitude and latitude") - if kwargs['member']: - if kwargs['longitude'] or kwargs['latitude']: - raise DataError("GEOSEARCH member and longitude or latitude" - " cant be set together") - pieces.extend([b'FROMMEMBER', kwargs['member']]) - if kwargs['longitude'] and kwargs['latitude']: - pieces.extend([b'FROMLONLAT', - kwargs['longitude'], kwargs['latitude']]) + if kwargs["member"] is None: + if kwargs["longitude"] is None or kwargs["latitude"] is None: + raise DataError( + "GEOSEARCH must have member or" " longitude and latitude" + ) + if kwargs["member"]: + if kwargs["longitude"] or kwargs["latitude"]: + raise DataError( + "GEOSEARCH member and longitude or latitude" " cant be set together" + ) + pieces.extend([b"FROMMEMBER", kwargs["member"]]) + if kwargs["longitude"] and kwargs["latitude"]: + pieces.extend([b"FROMLONLAT", kwargs["longitude"], kwargs["latitude"]]) # BYRADIUS or BYBOX - if kwargs['radius'] is None: - if kwargs['width'] is None or kwargs['height'] is None: - raise DataError("GEOSEARCH must have radius or" - " width and height") - if kwargs['unit'] is None: + if kwargs["radius"] is None: + if kwargs["width"] is None or kwargs["height"] is None: + raise DataError("GEOSEARCH must have radius or" " width and height") + if kwargs["unit"] is None: raise DataError("GEOSEARCH must have unit") - if kwargs['unit'].lower() not in ('m', 'km', 'mi', 'ft'): + if kwargs["unit"].lower() not in ("m", "km", "mi", "ft"): raise DataError("GEOSEARCH invalid unit") - if kwargs['radius']: - if kwargs['width'] or kwargs['height']: - raise DataError("GEOSEARCH radius and width or height" - " cant be set together") - pieces.extend([b'BYRADIUS', kwargs['radius'], kwargs['unit']]) - if kwargs['width'] and kwargs['height']: - pieces.extend([b'BYBOX', - kwargs['width'], kwargs['height'], kwargs['unit']]) + if kwargs["radius"]: + if kwargs["width"] or kwargs["height"]: + raise DataError( + "GEOSEARCH radius and width or height" " cant be set together" + ) + pieces.extend([b"BYRADIUS", kwargs["radius"], kwargs["unit"]]) + if kwargs["width"] and kwargs["height"]: + pieces.extend([b"BYBOX", kwargs["width"], kwargs["height"], kwargs["unit"]]) # sort - if kwargs['sort']: - if kwargs['sort'].upper() == 'ASC': - pieces.append(b'ASC') - elif kwargs['sort'].upper() == 'DESC': - pieces.append(b'DESC') + if kwargs["sort"]: + if kwargs["sort"].upper() == "ASC": + pieces.append(b"ASC") + elif kwargs["sort"].upper() == "DESC": + pieces.append(b"DESC") else: raise DataError("GEOSEARCH invalid sort") # count any - if kwargs['count']: - pieces.extend([b'COUNT', kwargs['count']]) - if kwargs['any']: - pieces.append(b'ANY') - elif kwargs['any']: - raise DataError("GEOSEARCH ``any`` can't be provided " - "without count") + if kwargs["count"]: + pieces.extend([b"COUNT", kwargs["count"]]) + if kwargs["any"]: + pieces.append(b"ANY") + elif kwargs["any"]: + raise DataError("GEOSEARCH ``any`` can't be provided " "without count") # other properties for arg_name, byte_repr in ( - ('withdist', b'WITHDIST'), - ('withcoord', b'WITHCOORD'), - ('withhash', b'WITHHASH'), - ('store_dist', b'STOREDIST')): + ("withdist", b"WITHDIST"), + ("withcoord", b"WITHCOORD"), + ("withhash", b"WITHHASH"), + ("store_dist", b"STOREDIST"), + ): if kwargs[arg_name]: pieces.append(byte_repr) @@ -3955,6 +4219,7 @@ class ModuleCommands: Redis Module commands. see: https://redis.io/topics/modules-intro """ + def module_load(self, path, *args): """ Loads the module from ``path``. @@ -3963,7 +4228,7 @@ def module_load(self, path, *args): For more information check https://redis.io/commands/module-load """ - return self.execute_command('MODULE LOAD', path, *args) + return self.execute_command("MODULE LOAD", path, *args) def module_unload(self, name): """ @@ -3972,7 +4237,7 @@ def module_unload(self, name): For more information check https://redis.io/commands/module-unload """ - return self.execute_command('MODULE UNLOAD', name) + return self.execute_command("MODULE UNLOAD", name) def module_list(self): """ @@ -3981,7 +4246,7 @@ def module_list(self): For more information check https://redis.io/commands/module-list """ - return self.execute_command('MODULE LIST') + return self.execute_command("MODULE LIST") def command_info(self): raise NotImplementedError( @@ -3989,13 +4254,13 @@ def command_info(self): ) def command_count(self): - return self.execute_command('COMMAND COUNT') + return self.execute_command("COMMAND COUNT") def command_getkeys(self, *args): - return self.execute_command('COMMAND GETKEYS', *args) + return self.execute_command("COMMAND GETKEYS", *args) def command(self): - return self.execute_command('COMMAND') + return self.execute_command("COMMAND") class Script: @@ -4022,6 +4287,7 @@ def __call__(self, keys=[], args=[], client=None): args = tuple(keys) + tuple(args) # make sure the Redis server knows about the script from redis.client import Pipeline + if isinstance(client, Pipeline): # Make sure the pipeline can register the script before executing. client.scripts.add(self) @@ -4039,6 +4305,7 @@ class BitFieldOperation: """ Command builder for BITFIELD commands. """ + def __init__(self, client, key, default_overflow=None): self.client = client self.key = key @@ -4050,7 +4317,7 @@ def reset(self): Reset the state of the instance to when it was constructed """ self.operations = [] - self._last_overflow = 'WRAP' + self._last_overflow = "WRAP" self.overflow(self._default_overflow or self._last_overflow) def overflow(self, overflow): @@ -4063,7 +4330,7 @@ def overflow(self, overflow): overflow = overflow.upper() if overflow != self._last_overflow: self._last_overflow = overflow - self.operations.append(('OVERFLOW', overflow)) + self.operations.append(("OVERFLOW", overflow)) return self def incrby(self, fmt, offset, increment, overflow=None): @@ -4083,7 +4350,7 @@ def incrby(self, fmt, offset, increment, overflow=None): if overflow is not None: self.overflow(overflow) - self.operations.append(('INCRBY', fmt, offset, increment)) + self.operations.append(("INCRBY", fmt, offset, increment)) return self def get(self, fmt, offset): @@ -4096,7 +4363,7 @@ def get(self, fmt, offset): fmt='u8', offset='#2', the offset will be 16. :returns: a :py:class:`BitFieldOperation` instance. """ - self.operations.append(('GET', fmt, offset)) + self.operations.append(("GET", fmt, offset)) return self def set(self, fmt, offset, value): @@ -4110,12 +4377,12 @@ def set(self, fmt, offset, value): :param int value: value to set at the given position. :returns: a :py:class:`BitFieldOperation` instance. """ - self.operations.append(('SET', fmt, offset, value)) + self.operations.append(("SET", fmt, offset, value)) return self @property def command(self): - cmd = ['BITFIELD', self.key] + cmd = ["BITFIELD", self.key] for ops in self.operations: cmd.extend(ops) return cmd @@ -4132,19 +4399,31 @@ def execute(self): return self.client.execute_command(*command) -class DataAccessCommands(BasicKeyCommands, ListCommands, - ScanCommands, SetCommands, StreamCommands, - SortedSetCommands, - HyperlogCommands, HashCommands, GeoCommands, - ): +class DataAccessCommands( + BasicKeyCommands, + ListCommands, + ScanCommands, + SetCommands, + StreamCommands, + SortedSetCommands, + HyperlogCommands, + HashCommands, + GeoCommands, +): """ A class containing all of the implemented data access redis commands. This class is to be used as a mixin. """ -class CoreCommands(ACLCommands, DataAccessCommands, ManagementCommands, - ModuleCommands, PubSubCommands, ScriptCommands): +class CoreCommands( + ACLCommands, + DataAccessCommands, + ManagementCommands, + ModuleCommands, + PubSubCommands, + ScriptCommands, +): """ A class containing all of the implemented redis commands. This class is to be used as a mixin. diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index dc5705b80b..80dfd76a15 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -22,7 +22,7 @@ def list_or_args(keys, args): def nativestr(x): """Return the decoded binary string, or a string, depending on type.""" r = x.decode("utf-8", "replace") if isinstance(x, bytes) else x - if r == 'null': + if r == "null": return return r @@ -58,14 +58,14 @@ def parse_list_to_dict(response): res = {} for i in range(0, len(response), 2): if isinstance(response[i], list): - res['Child iterators'].append(parse_list_to_dict(response[i])) - elif isinstance(response[i+1], list): - res['Child iterators'] = [parse_list_to_dict(response[i+1])] + res["Child iterators"].append(parse_list_to_dict(response[i])) + elif isinstance(response[i + 1], list): + res["Child iterators"] = [parse_list_to_dict(response[i + 1])] else: try: - res[response[i]] = float(response[i+1]) + res[response[i]] = float(response[i + 1]) except (TypeError, ValueError): - res[response[i]] = response[i+1] + res[response[i]] = response[i + 1] return res diff --git a/redis/commands/json/__init__.py b/redis/commands/json/__init__.py index d634dbd3f4..12c0648722 100644 --- a/redis/commands/json/__init__.py +++ b/redis/commands/json/__init__.py @@ -1,12 +1,10 @@ -from json import JSONDecoder, JSONEncoder, JSONDecodeError +from json import JSONDecodeError, JSONDecoder, JSONEncoder + +import redis -from .decoders import ( - decode_list, - bulk_of_jsons, -) from ..helpers import nativestr from .commands import JSONCommands -import redis +from .decoders import bulk_of_jsons, decode_list class JSON(JSONCommands): diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py index 1affaafaf6..e7f07b612f 100644 --- a/redis/commands/json/commands.py +++ b/redis/commands/json/commands.py @@ -1,8 +1,10 @@ -from .path import Path -from .decoders import decode_dict_keys from deprecated import deprecated + from redis.exceptions import DataError +from .decoders import decode_dict_keys +from .path import Path + class JSONCommands: """json commands.""" @@ -29,8 +31,7 @@ def arrindex(self, name, path, scalar, start=0, stop=-1): For more information: https://oss.redis.com/redisjson/commands/#jsonarrindex """ # noqa return self.execute_command( - "JSON.ARRINDEX", name, str(path), self._encode(scalar), - start, stop + "JSON.ARRINDEX", name, str(path), self._encode(scalar), start, stop ) def arrinsert(self, name, path, index, *args): @@ -66,8 +67,7 @@ def arrtrim(self, name, path, start, stop): For more information: https://oss.redis.com/redisjson/commands/#jsonarrtrim """ # noqa - return self.execute_command("JSON.ARRTRIM", name, str(path), - start, stop) + return self.execute_command("JSON.ARRTRIM", name, str(path), start, stop) def type(self, name, path=Path.rootPath()): """Get the type of the JSON value under ``path`` from key ``name``. @@ -109,7 +109,7 @@ def numincrby(self, name, path, number): "JSON.NUMINCRBY", name, str(path), self._encode(number) ) - @deprecated(version='4.0.0', reason='deprecated since redisjson 1.0.0') + @deprecated(version="4.0.0", reason="deprecated since redisjson 1.0.0") def nummultby(self, name, path, number): """Multiply the numeric (integer or floating point) JSON value under ``path`` at key ``name`` with the provided ``number``. @@ -218,7 +218,7 @@ def strlen(self, name, path=None): ``name``. For more information: https://oss.redis.com/redisjson/commands/#jsonstrlen - """ # noqa + """ # noqa pieces = [name] if path is not None: pieces.append(str(path)) @@ -240,9 +240,7 @@ def strappend(self, name, value, path=Path.rootPath()): For more information: https://oss.redis.com/redisjson/commands/#jsonstrappend """ # noqa pieces = [name, str(path), self._encode(value)] - return self.execute_command( - "JSON.STRAPPEND", *pieces - ) + return self.execute_command("JSON.STRAPPEND", *pieces) def debug(self, subcommand, key=None, path=Path.rootPath()): """Return the memory usage in bytes of a value under ``path`` from @@ -252,8 +250,7 @@ def debug(self, subcommand, key=None, path=Path.rootPath()): """ # noqa valid_subcommands = ["MEMORY", "HELP"] if subcommand not in valid_subcommands: - raise DataError("The only valid subcommands are ", - str(valid_subcommands)) + raise DataError("The only valid subcommands are ", str(valid_subcommands)) pieces = [subcommand] if subcommand == "MEMORY": if key is None: @@ -262,17 +259,20 @@ def debug(self, subcommand, key=None, path=Path.rootPath()): pieces.append(str(path)) return self.execute_command("JSON.DEBUG", *pieces) - @deprecated(version='4.0.0', - reason='redisjson-py supported this, call get directly.') + @deprecated( + version="4.0.0", reason="redisjson-py supported this, call get directly." + ) def jsonget(self, *args, **kwargs): return self.get(*args, **kwargs) - @deprecated(version='4.0.0', - reason='redisjson-py supported this, call get directly.') + @deprecated( + version="4.0.0", reason="redisjson-py supported this, call get directly." + ) def jsonmget(self, *args, **kwargs): return self.mget(*args, **kwargs) - @deprecated(version='4.0.0', - reason='redisjson-py supported this, call get directly.') + @deprecated( + version="4.0.0", reason="redisjson-py supported this, call get directly." + ) def jsonset(self, *args, **kwargs): return self.set(*args, **kwargs) diff --git a/redis/commands/json/decoders.py b/redis/commands/json/decoders.py index b19395c73b..b93847112b 100644 --- a/redis/commands/json/decoders.py +++ b/redis/commands/json/decoders.py @@ -1,6 +1,7 @@ -from ..helpers import nativestr -import re import copy +import re + +from ..helpers import nativestr def bulk_of_jsons(d): @@ -33,7 +34,7 @@ def unstring(obj): One can't simply call int/float in a try/catch because there is a semantic difference between (for example) 15.0 and 15. """ - floatreg = '^\\d+.\\d+$' + floatreg = "^\\d+.\\d+$" match = re.findall(floatreg, obj) if match != []: return float(match[0]) diff --git a/redis/commands/parser.py b/redis/commands/parser.py index 26b190c674..dadf3c6bf8 100644 --- a/redis/commands/parser.py +++ b/redis/commands/parser.py @@ -1,7 +1,4 @@ -from redis.exceptions import ( - RedisError, - ResponseError -) +from redis.exceptions import RedisError, ResponseError from redis.utils import str_if_bytes @@ -13,6 +10,7 @@ class CommandsParser: 'movablekeys', and these commands' keys are determined by the command 'COMMAND GETKEYS'. """ + def __init__(self, redis_connection): self.initialized = False self.commands = {} @@ -51,20 +49,24 @@ def get_keys(self, redis_conn, *args): ) command = self.commands.get(cmd_name) - if 'movablekeys' in command['flags']: + if "movablekeys" in command["flags"]: keys = self._get_moveable_keys(redis_conn, *args) - elif 'pubsub' in command['flags']: + elif "pubsub" in command["flags"]: keys = self._get_pubsub_keys(*args) else: - if command['step_count'] == 0 and command['first_key_pos'] == 0 \ - and command['last_key_pos'] == 0: + if ( + command["step_count"] == 0 + and command["first_key_pos"] == 0 + and command["last_key_pos"] == 0 + ): # The command doesn't have keys in it return None - last_key_pos = command['last_key_pos'] + last_key_pos = command["last_key_pos"] if last_key_pos < 0: last_key_pos = len(args) - abs(last_key_pos) - keys_pos = list(range(command['first_key_pos'], last_key_pos + 1, - command['step_count'])) + keys_pos = list( + range(command["first_key_pos"], last_key_pos + 1, command["step_count"]) + ) keys = [args[pos] for pos in keys_pos] return keys @@ -77,11 +79,13 @@ def _get_moveable_keys(self, redis_conn, *args): pieces = pieces + cmd_name.split() pieces = pieces + list(args[1:]) try: - keys = redis_conn.execute_command('COMMAND GETKEYS', *pieces) + keys = redis_conn.execute_command("COMMAND GETKEYS", *pieces) except ResponseError as e: message = e.__str__() - if 'Invalid arguments' in message or \ - 'The command has no key arguments' in message: + if ( + "Invalid arguments" in message + or "The command has no key arguments" in message + ): return None else: raise e @@ -99,18 +103,17 @@ def _get_pubsub_keys(self, *args): return None args = [str_if_bytes(arg) for arg in args] command = args[0].upper() - if command == 'PUBSUB': + if command == "PUBSUB": # the second argument is a part of the command name, e.g. # ['PUBSUB', 'NUMSUB', 'foo']. pubsub_type = args[1].upper() - if pubsub_type in ['CHANNELS', 'NUMSUB']: + if pubsub_type in ["CHANNELS", "NUMSUB"]: keys = args[2:] - elif command in ['SUBSCRIBE', 'PSUBSCRIBE', 'UNSUBSCRIBE', - 'PUNSUBSCRIBE']: + elif command in ["SUBSCRIBE", "PSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE"]: # format example: # SUBSCRIBE channel [channel ...] keys = list(args[1:]) - elif command == 'PUBLISH': + elif command == "PUBLISH": # format example: # PUBLISH channel message keys = [args[1]] diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py index 5f629fb5ea..2420d7b6fb 100644 --- a/redis/commands/redismodules.py +++ b/redis/commands/redismodules.py @@ -1,4 +1,4 @@ -from json import JSONEncoder, JSONDecoder +from json import JSONDecoder, JSONEncoder class RedisModuleCommands: @@ -7,21 +7,18 @@ class RedisModuleCommands: """ def json(self, encoder=JSONEncoder(), decoder=JSONDecoder()): - """Access the json namespace, providing support for redis json. - """ + """Access the json namespace, providing support for redis json.""" from .json import JSON - jj = JSON( - client=self, - encoder=encoder, - decoder=decoder) + + jj = JSON(client=self, encoder=encoder, decoder=decoder) return jj def ft(self, index_name="idx"): - """Access the search namespace, providing support for redis search. - """ + """Access the search namespace, providing support for redis search.""" from .search import Search + s = Search(client=self, index_name=index_name) return s @@ -31,5 +28,6 @@ def ts(self): """ from .timeseries import TimeSeries + s = TimeSeries(client=self) return s diff --git a/redis/commands/search/__init__.py b/redis/commands/search/__init__.py index a30cebe1b7..94bc037c3d 100644 --- a/redis/commands/search/__init__.py +++ b/redis/commands/search/__init__.py @@ -35,7 +35,7 @@ def add_document( replace=False, partial=False, no_create=False, - **fields + **fields, ): """ Add a document to the batch query @@ -49,7 +49,7 @@ def add_document( replace=replace, partial=partial, no_create=no_create, - **fields + **fields, ) self.current_chunk += 1 self.total += 1 diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py index 553bc39839..4ec6fc9dfa 100644 --- a/redis/commands/search/commands.py +++ b/redis/commands/search/commands.py @@ -1,13 +1,13 @@ import itertools import time -from .document import Document -from .result import Result -from .query import Query +from ..helpers import parse_to_dict from ._util import to_string from .aggregation import AggregateRequest, AggregateResult, Cursor +from .document import Document +from .query import Query +from .result import Result from .suggestion import SuggestionParser -from ..helpers import parse_to_dict NUMERIC = "NUMERIC" @@ -148,7 +148,7 @@ def _add_document( partial=False, language=None, no_create=False, - **fields + **fields, ): """ Internal add_document used for both batch and single doc indexing @@ -211,7 +211,7 @@ def add_document( partial=False, language=None, no_create=False, - **fields + **fields, ): """ Add a single document to the index. @@ -253,7 +253,7 @@ def add_document( partial=partial, language=language, no_create=no_create, - **fields + **fields, ) def add_document_hash( @@ -274,7 +274,7 @@ def add_document_hash( - **replace**: if True, and the document already is in the index, we perform an update and reindex the document - **language**: Specify the language used for document tokenization. - + For more information: https://oss.redis.com/redisearch/Commands/#ftaddhash """ # noqa return self._add_document_hash( @@ -294,7 +294,7 @@ def delete_document(self, doc_id, conn=None, delete_actual_document=False): - **delete_actual_document**: if set to True, RediSearch also delete the actual document if it is in the index - + For more information: https://oss.redis.com/redisearch/Commands/#ftdel """ # noqa args = [DEL_CMD, self.index_name, doc_id] @@ -453,7 +453,7 @@ def profile(self, query, limited=False): cmd = [PROFILE_CMD, self.index_name, ""] if limited: cmd.append("LIMITED") - cmd.append('QUERY') + cmd.append("QUERY") if isinstance(query, AggregateRequest): cmd[2] = "AGGREGATE" @@ -462,19 +462,20 @@ def profile(self, query, limited=False): cmd[2] = "SEARCH" cmd += query.get_args() else: - raise ValueError("Must provide AggregateRequest object or " - "Query object.") + raise ValueError("Must provide AggregateRequest object or " "Query object.") res = self.execute_command(*cmd) if isinstance(query, AggregateRequest): result = self._get_AggregateResult(res[0], query, query._cursor) else: - result = Result(res[0], - not query._no_content, - duration=(time.time() - st) * 1000.0, - has_payload=query._with_payloads, - with_scores=query._with_scores,) + result = Result( + res[0], + not query._no_content, + duration=(time.time() - st) * 1000.0, + has_payload=query._with_payloads, + with_scores=query._with_scores, + ) return result, parse_to_dict(res[1]) @@ -535,8 +536,7 @@ def spellcheck(self, query, distance=None, include=None, exclude=None): # ] # } corrections[_correction[1]] = [ - {"score": _item[0], "suggestion": _item[1]} - for _item in _correction[2] + {"score": _item[0], "suggestion": _item[1]} for _item in _correction[2] ] return corrections @@ -704,8 +704,7 @@ def sugdel(self, key, string): return self.execute_command(SUGDEL_COMMAND, key, string) def sugget( - self, key, prefix, fuzzy=False, num=10, with_scores=False, - with_payloads=False + self, key, prefix, fuzzy=False, num=10, with_scores=False, with_payloads=False ): """ Get a list of suggestions from the AutoCompleter, for a given prefix. @@ -769,7 +768,7 @@ def synupdate(self, groupid, skipinitial=False, *terms): If set to true, we do not scan and index. terms : The terms. - + For more information: https://oss.redis.com/redisearch/Commands/#ftsynupdate """ # noqa cmd = [SYNUPDATE_CMD, self.index_name, groupid] diff --git a/redis/commands/search/field.py b/redis/commands/search/field.py index 076c872b62..69e39083b0 100644 --- a/redis/commands/search/field.py +++ b/redis/commands/search/field.py @@ -9,8 +9,7 @@ class Field: NOINDEX = "NOINDEX" AS = "AS" - def __init__(self, name, args=[], sortable=False, - no_index=False, as_name=None): + def __init__(self, name, args=[], sortable=False, no_index=False, as_name=None): self.name = name self.args = args self.args_suffix = list() @@ -47,8 +46,7 @@ class TextField(Field): def __init__( self, name, weight=1.0, no_stem=False, phonetic_matcher=None, **kwargs ): - Field.__init__(self, name, - args=[Field.TEXT, Field.WEIGHT, weight], **kwargs) + Field.__init__(self, name, args=[Field.TEXT, Field.WEIGHT, weight], **kwargs) if no_stem: Field.append_arg(self, self.NOSTEM) diff --git a/redis/commands/search/query.py b/redis/commands/search/query.py index 5534f7b88e..2bb8347dbc 100644 --- a/redis/commands/search/query.py +++ b/redis/commands/search/query.py @@ -62,11 +62,9 @@ def return_field(self, field, as_field=None): def _mk_field_list(self, fields): if not fields: return [] - return \ - [fields] if isinstance(fields, str) else list(fields) + return [fields] if isinstance(fields, str) else list(fields) - def summarize(self, fields=None, context_len=None, - num_frags=None, sep=None): + def summarize(self, fields=None, context_len=None, num_frags=None, sep=None): """ Return an abridged format of the field, containing only the segments of the field which contain the matching term(s). @@ -300,8 +298,7 @@ class NumericFilter(Filter): INF = "+inf" NEG_INF = "-inf" - def __init__(self, field, minval, maxval, minExclusive=False, - maxExclusive=False): + def __init__(self, field, minval, maxval, minExclusive=False, maxExclusive=False): args = [ minval if not minExclusive else f"({minval}", maxval if not maxExclusive else f"({maxval}", diff --git a/redis/commands/search/querystring.py b/redis/commands/search/querystring.py index ffba542e31..1da0387eb8 100644 --- a/redis/commands/search/querystring.py +++ b/redis/commands/search/querystring.py @@ -15,8 +15,7 @@ def between(a, b, inclusive_min=True, inclusive_max=True): """ Indicate that value is a numeric range """ - return RangeValue(a, b, inclusive_min=inclusive_min, - inclusive_max=inclusive_max) + return RangeValue(a, b, inclusive_min=inclusive_min, inclusive_max=inclusive_max) def equal(n): @@ -200,9 +199,7 @@ def join_fields(self, key, vals): return [BaseNode(f"@{key}:{vals[0].to_string()}")] if not vals[0].combinable: return [BaseNode(f"@{key}:{v.to_string()}") for v in vals] - s = BaseNode( - f"@{key}:({self.JOINSTR.join(v.to_string() for v in vals)})" - ) + s = BaseNode(f"@{key}:({self.JOINSTR.join(v.to_string() for v in vals)})") return [s] @classmethod diff --git a/redis/commands/search/result.py b/redis/commands/search/result.py index 57ba53d5ca..5f4aca6411 100644 --- a/redis/commands/search/result.py +++ b/redis/commands/search/result.py @@ -1,5 +1,5 @@ -from .document import Document from ._util import to_string +from .document import Document class Result: diff --git a/redis/commands/search/suggestion.py b/redis/commands/search/suggestion.py index 6d295a652f..5d1eba64b8 100644 --- a/redis/commands/search/suggestion.py +++ b/redis/commands/search/suggestion.py @@ -46,8 +46,6 @@ def __init__(self, with_scores, with_payloads, ret): def __iter__(self): for i in range(0, len(self._sugs), self.sugsize): ss = self._sugs[i] - score = float(self._sugs[i + self._scoreidx]) \ - if self.with_scores else 1.0 - payload = self._sugs[i + self._payloadidx] \ - if self.with_payloads else None + score = float(self._sugs[i + self._scoreidx]) if self.with_scores else 1.0 + payload = self._sugs[i + self._payloadidx] if self.with_payloads else None yield Suggestion(ss, score, payload) diff --git a/redis/commands/sentinel.py b/redis/commands/sentinel.py index 1f02984bed..a9b06c2f6e 100644 --- a/redis/commands/sentinel.py +++ b/redis/commands/sentinel.py @@ -9,41 +9,39 @@ class SentinelCommands: def sentinel(self, *args): "Redis Sentinel's SENTINEL command." - warnings.warn( - DeprecationWarning('Use the individual sentinel_* methods')) + warnings.warn(DeprecationWarning("Use the individual sentinel_* methods")) def sentinel_get_master_addr_by_name(self, service_name): "Returns a (host, port) pair for the given ``service_name``" - return self.execute_command('SENTINEL GET-MASTER-ADDR-BY-NAME', - service_name) + return self.execute_command("SENTINEL GET-MASTER-ADDR-BY-NAME", service_name) def sentinel_master(self, service_name): "Returns a dictionary containing the specified masters state." - return self.execute_command('SENTINEL MASTER', service_name) + return self.execute_command("SENTINEL MASTER", service_name) def sentinel_masters(self): "Returns a list of dictionaries containing each master's state." - return self.execute_command('SENTINEL MASTERS') + return self.execute_command("SENTINEL MASTERS") def sentinel_monitor(self, name, ip, port, quorum): "Add a new master to Sentinel to be monitored" - return self.execute_command('SENTINEL MONITOR', name, ip, port, quorum) + return self.execute_command("SENTINEL MONITOR", name, ip, port, quorum) def sentinel_remove(self, name): "Remove a master from Sentinel's monitoring" - return self.execute_command('SENTINEL REMOVE', name) + return self.execute_command("SENTINEL REMOVE", name) def sentinel_sentinels(self, service_name): "Returns a list of sentinels for ``service_name``" - return self.execute_command('SENTINEL SENTINELS', service_name) + return self.execute_command("SENTINEL SENTINELS", service_name) def sentinel_set(self, name, option, value): "Set Sentinel monitoring parameters for a given master" - return self.execute_command('SENTINEL SET', name, option, value) + return self.execute_command("SENTINEL SET", name, option, value) def sentinel_slaves(self, service_name): "Returns a list of slaves for ``service_name``" - return self.execute_command('SENTINEL SLAVES', service_name) + return self.execute_command("SENTINEL SLAVES", service_name) def sentinel_reset(self, pattern): """ @@ -54,7 +52,7 @@ def sentinel_reset(self, pattern): failover in progress), and removes every slave and sentinel already discovered and associated with the master. """ - return self.execute_command('SENTINEL RESET', pattern, once=True) + return self.execute_command("SENTINEL RESET", pattern, once=True) def sentinel_failover(self, new_master_name): """ @@ -63,7 +61,7 @@ def sentinel_failover(self, new_master_name): configuration will be published so that the other Sentinels will update their configurations). """ - return self.execute_command('SENTINEL FAILOVER', new_master_name) + return self.execute_command("SENTINEL FAILOVER", new_master_name) def sentinel_ckquorum(self, new_master_name): """ @@ -74,9 +72,7 @@ def sentinel_ckquorum(self, new_master_name): This command should be used in monitoring systems to check if a Sentinel deployment is ok. """ - return self.execute_command('SENTINEL CKQUORUM', - new_master_name, - once=True) + return self.execute_command("SENTINEL CKQUORUM", new_master_name, once=True) def sentinel_flushconfig(self): """ @@ -94,4 +90,4 @@ def sentinel_flushconfig(self): This command works even if the previous configuration file is completely missing. """ - return self.execute_command('SENTINEL FLUSHCONFIG') + return self.execute_command("SENTINEL FLUSHCONFIG") diff --git a/redis/commands/timeseries/__init__.py b/redis/commands/timeseries/__init__.py index 5ce538f675..5b1f15114d 100644 --- a/redis/commands/timeseries/__init__.py +++ b/redis/commands/timeseries/__init__.py @@ -1,19 +1,12 @@ import redis.client -from .utils import ( - parse_range, - parse_get, - parse_m_range, - parse_m_get, -) -from .info import TSInfo from ..helpers import parse_to_list from .commands import ( ALTER_CMD, CREATE_CMD, CREATERULE_CMD, - DELETERULE_CMD, DEL_CMD, + DELETERULE_CMD, GET_CMD, INFO_CMD, MGET_CMD, @@ -24,6 +17,8 @@ REVRANGE_CMD, TimeSeriesCommands, ) +from .info import TSInfo +from .utils import parse_get, parse_m_get, parse_m_range, parse_range class TimeSeries(TimeSeriesCommands): diff --git a/redis/commands/timeseries/commands.py b/redis/commands/timeseries/commands.py index 460ba766a9..c86e0b98b7 100644 --- a/redis/commands/timeseries/commands.py +++ b/redis/commands/timeseries/commands.py @@ -1,6 +1,5 @@ from redis.exceptions import DataError - ADD_CMD = "TS.ADD" ALTER_CMD = "TS.ALTER" CREATERULE_CMD = "TS.CREATERULE" @@ -58,7 +57,7 @@ def create(self, key, **kwargs): - 'min': only override if the value is lower than the existing value. - 'max': only override if the value is higher than the existing value. When this is not set, the server-wide default will be used. - + For more information: https://oss.redis.com/redistimeseries/commands/#tscreate """ # noqa retention_msecs = kwargs.get("retention_msecs", None) @@ -81,7 +80,7 @@ def alter(self, key, **kwargs): For more information see The parameters are the same as TS.CREATE. - + For more information: https://oss.redis.com/redistimeseries/commands/#tsalter """ # noqa retention_msecs = kwargs.get("retention_msecs", None) @@ -129,7 +128,7 @@ def add(self, key, timestamp, value, **kwargs): - 'min': only override if the value is lower than the existing value. - 'max': only override if the value is higher than the existing value. When this is not set, the server-wide default will be used. - + For more information: https://oss.redis.com/redistimeseries/master/commands/#tsadd """ # noqa retention_msecs = kwargs.get("retention_msecs", None) @@ -276,13 +275,7 @@ def delete(self, key, from_time, to_time): """ # noqa return self.execute_command(DEL_CMD, key, from_time, to_time) - def createrule( - self, - source_key, - dest_key, - aggregation_type, - bucket_size_msec - ): + def createrule(self, source_key, dest_key, aggregation_type, bucket_size_msec): """ Create a compaction rule from values added to `source_key` into `dest_key`. Aggregating for `bucket_size_msec` where an `aggregation_type` can be @@ -321,11 +314,7 @@ def __range_params( """Create TS.RANGE and TS.REVRANGE arguments.""" params = [key, from_time, to_time] self._appendFilerByTs(params, filter_by_ts) - self._appendFilerByValue( - params, - filter_by_min_value, - filter_by_max_value - ) + self._appendFilerByValue(params, filter_by_min_value, filter_by_max_value) self._appendCount(params, count) self._appendAlign(params, align) self._appendAggregation(params, aggregation_type, bucket_size_msec) @@ -471,11 +460,7 @@ def __mrange_params( """Create TS.MRANGE and TS.MREVRANGE arguments.""" params = [from_time, to_time] self._appendFilerByTs(params, filter_by_ts) - self._appendFilerByValue( - params, - filter_by_min_value, - filter_by_max_value - ) + self._appendFilerByValue(params, filter_by_min_value, filter_by_max_value) self._appendCount(params, count) self._appendAlign(params, align) self._appendAggregation(params, aggregation_type, bucket_size_msec) @@ -654,7 +639,7 @@ def mrevrange( return self.execute_command(MREVRANGE_CMD, *params) def get(self, key): - """ # noqa + """# noqa Get the last sample of `key`. For more information: https://oss.redis.com/redistimeseries/master/commands/#tsget @@ -662,7 +647,7 @@ def get(self, key): return self.execute_command(GET_CMD, key) def mget(self, filters, with_labels=False): - """ # noqa + """# noqa Get the last samples matching the specific `filter`. For more information: https://oss.redis.com/redistimeseries/master/commands/#tsmget @@ -674,7 +659,7 @@ def mget(self, filters, with_labels=False): return self.execute_command(MGET_CMD, *params) def info(self, key): - """ # noqa + """# noqa Get information of `key`. For more information: https://oss.redis.com/redistimeseries/master/commands/#tsinfo @@ -682,7 +667,7 @@ def info(self, key): return self.execute_command(INFO_CMD, key) def queryindex(self, filters): - """ # noqa + """# noqa Get all the keys matching the `filter` list. For more information: https://oss.redis.com/redistimeseries/master/commands/#tsqueryindex diff --git a/redis/commands/timeseries/info.py b/redis/commands/timeseries/info.py index 2b8acd1b66..fba7f093b1 100644 --- a/redis/commands/timeseries/info.py +++ b/redis/commands/timeseries/info.py @@ -1,5 +1,5 @@ -from .utils import list_to_dict from ..helpers import nativestr +from .utils import list_to_dict class TSInfo: diff --git a/redis/commands/timeseries/utils.py b/redis/commands/timeseries/utils.py index c33b7c591e..c49b040271 100644 --- a/redis/commands/timeseries/utils.py +++ b/redis/commands/timeseries/utils.py @@ -2,9 +2,7 @@ def list_to_dict(aList): - return { - nativestr(aList[i][0]): nativestr(aList[i][1]) - for i in range(len(aList))} + return {nativestr(aList[i][0]): nativestr(aList[i][1]) for i in range(len(aList))} def parse_range(response): @@ -16,9 +14,7 @@ def parse_m_range(response): """Parse multi range response. Used by TS.MRANGE and TS.MREVRANGE.""" res = [] for item in response: - res.append( - {nativestr(item[0]): - [list_to_dict(item[1]), parse_range(item[2])]}) + res.append({nativestr(item[0]): [list_to_dict(item[1]), parse_range(item[2])]}) return sorted(res, key=lambda d: list(d.keys())) @@ -34,8 +30,7 @@ def parse_m_get(response): res = [] for item in response: if not item[2]: - res.append( - {nativestr(item[0]): [list_to_dict(item[1]), None, None]}) + res.append({nativestr(item[0]): [list_to_dict(item[1]), None, None]}) else: res.append( { diff --git a/redis/connection.py b/redis/connection.py index ef3a667c16..d13fe65ef8 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -1,8 +1,3 @@ -from packaging.version import Version -from itertools import chain -from time import time -from queue import LifoQueue, Empty, Full -from urllib.parse import parse_qs, unquote, urlparse import copy import errno import io @@ -10,6 +5,12 @@ import socket import threading import weakref +from itertools import chain +from queue import Empty, Full, LifoQueue +from time import time +from urllib.parse import parse_qs, unquote, urlparse + +from packaging.version import Version from redis.backoff import NoBackoff from redis.exceptions import ( @@ -21,20 +22,20 @@ DataError, ExecAbortError, InvalidResponse, + ModuleError, NoPermissionError, NoScriptError, ReadOnlyError, RedisError, ResponseError, TimeoutError, - ModuleError, ) - from redis.retry import Retry from redis.utils import HIREDIS_AVAILABLE, str_if_bytes try: import ssl + ssl_available = True except ImportError: ssl_available = False @@ -44,7 +45,7 @@ } if ssl_available: - if hasattr(ssl, 'SSLWantReadError'): + if hasattr(ssl, "SSLWantReadError"): NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLWantReadError] = 2 NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLWantWriteError] = 2 else: @@ -56,34 +57,31 @@ import hiredis hiredis_version = Version(hiredis.__version__) - HIREDIS_SUPPORTS_CALLABLE_ERRORS = \ - hiredis_version >= Version('0.1.3') - HIREDIS_SUPPORTS_BYTE_BUFFER = \ - hiredis_version >= Version('0.1.4') - HIREDIS_SUPPORTS_ENCODING_ERRORS = \ - hiredis_version >= Version('1.0.0') + HIREDIS_SUPPORTS_CALLABLE_ERRORS = hiredis_version >= Version("0.1.3") + HIREDIS_SUPPORTS_BYTE_BUFFER = hiredis_version >= Version("0.1.4") + HIREDIS_SUPPORTS_ENCODING_ERRORS = hiredis_version >= Version("1.0.0") HIREDIS_USE_BYTE_BUFFER = True # only use byte buffer if hiredis supports it if not HIREDIS_SUPPORTS_BYTE_BUFFER: HIREDIS_USE_BYTE_BUFFER = False -SYM_STAR = b'*' -SYM_DOLLAR = b'$' -SYM_CRLF = b'\r\n' -SYM_EMPTY = b'' +SYM_STAR = b"*" +SYM_DOLLAR = b"$" +SYM_CRLF = b"\r\n" +SYM_EMPTY = b"" SERVER_CLOSED_CONNECTION_ERROR = "Connection closed by server." SENTINEL = object() -MODULE_LOAD_ERROR = 'Error loading the extension. ' \ - 'Please check the server logs.' -NO_SUCH_MODULE_ERROR = 'Error unloading module: no such module with that name' -MODULE_UNLOAD_NOT_POSSIBLE_ERROR = 'Error unloading module: operation not ' \ - 'possible.' -MODULE_EXPORTS_DATA_TYPES_ERROR = "Error unloading module: the module " \ - "exports one or more module-side data " \ - "types, can't unload" +MODULE_LOAD_ERROR = "Error loading the extension. " "Please check the server logs." +NO_SUCH_MODULE_ERROR = "Error unloading module: no such module with that name" +MODULE_UNLOAD_NOT_POSSIBLE_ERROR = "Error unloading module: operation not " "possible." +MODULE_EXPORTS_DATA_TYPES_ERROR = ( + "Error unloading module: the module " + "exports one or more module-side data " + "types, can't unload" +) class Encoder: @@ -100,15 +98,19 @@ def encode(self, value): return value elif isinstance(value, bool): # special case bool since it is a subclass of int - raise DataError("Invalid input of type: 'bool'. Convert to a " - "bytes, string, int or float first.") + raise DataError( + "Invalid input of type: 'bool'. Convert to a " + "bytes, string, int or float first." + ) elif isinstance(value, (int, float)): value = repr(value).encode() elif not isinstance(value, str): # a value we don't know how to deal with. throw an error typename = type(value).__name__ - raise DataError(f"Invalid input of type: '{typename}'. " - f"Convert to a bytes, string, int or float first.") + raise DataError( + f"Invalid input of type: '{typename}'. " + f"Convert to a bytes, string, int or float first." + ) if isinstance(value, str): value = value.encode(self.encoding, self.encoding_errors) return value @@ -125,36 +127,36 @@ def decode(self, value, force=False): class BaseParser: EXCEPTION_CLASSES = { - 'ERR': { - 'max number of clients reached': ConnectionError, - 'Client sent AUTH, but no password is set': AuthenticationError, - 'invalid password': AuthenticationError, + "ERR": { + "max number of clients reached": ConnectionError, + "Client sent AUTH, but no password is set": AuthenticationError, + "invalid password": AuthenticationError, # some Redis server versions report invalid command syntax # in lowercase - 'wrong number of arguments for \'auth\' command': - AuthenticationWrongNumberOfArgsError, + "wrong number of arguments " + "for 'auth' command": AuthenticationWrongNumberOfArgsError, # some Redis server versions report invalid command syntax # in uppercase - 'wrong number of arguments for \'AUTH\' command': - AuthenticationWrongNumberOfArgsError, + "wrong number of arguments " + "for 'AUTH' command": AuthenticationWrongNumberOfArgsError, MODULE_LOAD_ERROR: ModuleError, MODULE_EXPORTS_DATA_TYPES_ERROR: ModuleError, NO_SUCH_MODULE_ERROR: ModuleError, MODULE_UNLOAD_NOT_POSSIBLE_ERROR: ModuleError, }, - 'EXECABORT': ExecAbortError, - 'LOADING': BusyLoadingError, - 'NOSCRIPT': NoScriptError, - 'READONLY': ReadOnlyError, - 'NOAUTH': AuthenticationError, - 'NOPERM': NoPermissionError, + "EXECABORT": ExecAbortError, + "LOADING": BusyLoadingError, + "NOSCRIPT": NoScriptError, + "READONLY": ReadOnlyError, + "NOAUTH": AuthenticationError, + "NOPERM": NoPermissionError, } def parse_error(self, response): "Parse an error response" - error_code = response.split(' ')[0] + error_code = response.split(" ")[0] if error_code in self.EXCEPTION_CLASSES: - response = response[len(error_code) + 1:] + response = response[len(error_code) + 1 :] exception_class = self.EXCEPTION_CLASSES[error_code] if isinstance(exception_class, dict): exception_class = exception_class.get(response, ResponseError) @@ -177,8 +179,7 @@ def __init__(self, socket, socket_read_size, socket_timeout): def length(self): return self.bytes_written - self.bytes_read - def _read_from_socket(self, length=None, timeout=SENTINEL, - raise_on_timeout=True): + def _read_from_socket(self, length=None, timeout=SENTINEL, raise_on_timeout=True): sock = self._sock socket_read_size = self.socket_read_size buf = self._buffer @@ -220,9 +221,9 @@ def _read_from_socket(self, length=None, timeout=SENTINEL, sock.settimeout(self.socket_timeout) def can_read(self, timeout): - return bool(self.length) or \ - self._read_from_socket(timeout=timeout, - raise_on_timeout=False) + return bool(self.length) or self._read_from_socket( + timeout=timeout, raise_on_timeout=False + ) def read(self, length): length = length + 2 # make sure to read the \r\n terminator @@ -283,6 +284,7 @@ def close(self): class PythonParser(BaseParser): "Plain Python parsing class" + def __init__(self, socket_read_size): self.socket_read_size = socket_read_size self.encoder = None @@ -298,9 +300,9 @@ def __del__(self): def on_connect(self, connection): "Called when the socket connects" self._sock = connection._sock - self._buffer = SocketBuffer(self._sock, - self.socket_read_size, - connection.socket_timeout) + self._buffer = SocketBuffer( + self._sock, self.socket_read_size, connection.socket_timeout + ) self.encoder = connection.encoder def on_disconnect(self): @@ -321,12 +323,12 @@ def read_response(self, disable_decoding=False): byte, response = raw[:1], raw[1:] - if byte not in (b'-', b'+', b':', b'$', b'*'): + if byte not in (b"-", b"+", b":", b"$", b"*"): raise InvalidResponse(f"Protocol Error: {raw!r}") # server returned an error - if byte == b'-': - response = response.decode('utf-8', errors='replace') + if byte == b"-": + response = response.decode("utf-8", errors="replace") error = self.parse_error(response) # if the error is a ConnectionError, raise immediately so the user # is notified @@ -338,24 +340,26 @@ def read_response(self, disable_decoding=False): # necessary, so just return the exception instance here. return error # single value - elif byte == b'+': + elif byte == b"+": pass # int value - elif byte == b':': + elif byte == b":": response = int(response) # bulk response - elif byte == b'$': + elif byte == b"$": length = int(response) if length == -1: return None response = self._buffer.read(length) # multi-bulk response - elif byte == b'*': + elif byte == b"*": length = int(response) if length == -1: return None - response = [self.read_response(disable_decoding=disable_decoding) - for i in range(length)] + response = [ + self.read_response(disable_decoding=disable_decoding) + for i in range(length) + ] if isinstance(response, bytes) and disable_decoding is False: response = self.encoder.decode(response) return response @@ -363,6 +367,7 @@ def read_response(self, disable_decoding=False): class HiredisParser(BaseParser): "Parser class for connections using Hiredis" + def __init__(self, socket_read_size): if not HIREDIS_AVAILABLE: raise RedisError("Hiredis is not installed") @@ -381,18 +386,18 @@ def on_connect(self, connection): self._sock = connection._sock self._socket_timeout = connection.socket_timeout kwargs = { - 'protocolError': InvalidResponse, - 'replyError': self.parse_error, + "protocolError": InvalidResponse, + "replyError": self.parse_error, } # hiredis < 0.1.3 doesn't support functions that create exceptions if not HIREDIS_SUPPORTS_CALLABLE_ERRORS: - kwargs['replyError'] = ResponseError + kwargs["replyError"] = ResponseError if connection.encoder.decode_responses: - kwargs['encoding'] = connection.encoder.encoding + kwargs["encoding"] = connection.encoder.encoding if HIREDIS_SUPPORTS_ENCODING_ERRORS: - kwargs['errors'] = connection.encoder.encoding_errors + kwargs["errors"] = connection.encoder.encoding_errors self._reader = hiredis.Reader(**kwargs) self._next_response = False @@ -408,8 +413,7 @@ def can_read(self, timeout): if self._next_response is False: self._next_response = self._reader.gets() if self._next_response is False: - return self.read_from_socket(timeout=timeout, - raise_on_timeout=False) + return self.read_from_socket(timeout=timeout, raise_on_timeout=False) return True def read_from_socket(self, timeout=SENTINEL, raise_on_timeout=True): @@ -468,16 +472,22 @@ def read_response(self, disable_decoding=False): if not HIREDIS_SUPPORTS_CALLABLE_ERRORS: if isinstance(response, ResponseError): response = self.parse_error(response.args[0]) - elif isinstance(response, list) and response and \ - isinstance(response[0], ResponseError): + elif ( + isinstance(response, list) + and response + and isinstance(response[0], ResponseError) + ): response[0] = self.parse_error(response[0].args[0]) # if the response is a ConnectionError or the response is a list and # the first item is a ConnectionError, raise it as something bad # happened if isinstance(response, ConnectionError): raise response - elif isinstance(response, list) and response and \ - isinstance(response[0], ConnectionError): + elif ( + isinstance(response, list) + and response + and isinstance(response[0], ConnectionError) + ): raise response[0] return response @@ -491,14 +501,29 @@ def read_response(self, disable_decoding=False): class Connection: "Manages TCP communication to and from a Redis server" - def __init__(self, host='localhost', port=6379, db=0, password=None, - socket_timeout=None, socket_connect_timeout=None, - socket_keepalive=False, socket_keepalive_options=None, - socket_type=0, retry_on_timeout=False, encoding='utf-8', - encoding_errors='strict', decode_responses=False, - parser_class=DefaultParser, socket_read_size=65536, - health_check_interval=0, client_name=None, username=None, - retry=None, redis_connect_func=None): + def __init__( + self, + host="localhost", + port=6379, + db=0, + password=None, + socket_timeout=None, + socket_connect_timeout=None, + socket_keepalive=False, + socket_keepalive_options=None, + socket_type=0, + retry_on_timeout=False, + encoding="utf-8", + encoding_errors="strict", + decode_responses=False, + parser_class=DefaultParser, + socket_read_size=65536, + health_check_interval=0, + client_name=None, + username=None, + retry=None, + redis_connect_func=None, + ): """ Initialize a new Connection. To specify a retry policy, first set `retry_on_timeout` to `True` @@ -536,17 +561,13 @@ def __init__(self, host='localhost', port=6379, db=0, password=None, self._buffer_cutoff = 6000 def __repr__(self): - repr_args = ','.join([f'{k}={v}' for k, v in self.repr_pieces()]) - return f'{self.__class__.__name__}<{repr_args}>' + repr_args = ",".join([f"{k}={v}" for k, v in self.repr_pieces()]) + return f"{self.__class__.__name__}<{repr_args}>" def repr_pieces(self): - pieces = [ - ('host', self.host), - ('port', self.port), - ('db', self.db) - ] + pieces = [("host", self.host), ("port", self.port), ("db", self.db)] if self.client_name: - pieces.append(('client_name', self.client_name)) + pieces.append(("client_name", self.client_name)) return pieces def __del__(self): @@ -606,8 +627,9 @@ def _connect(self): # ipv4/ipv6, but we want to set options prior to calling # socket.connect() err = None - for res in socket.getaddrinfo(self.host, self.port, self.socket_type, - socket.SOCK_STREAM): + for res in socket.getaddrinfo( + self.host, self.port, self.socket_type, socket.SOCK_STREAM + ): family, socktype, proto, canonname, socket_address = res sock = None try: @@ -658,12 +680,12 @@ def on_connect(self): # if username and/or password are set, authenticate if self.username or self.password: if self.username: - auth_args = (self.username, self.password or '') + auth_args = (self.username, self.password or "") else: auth_args = (self.password,) # avoid checking health here -- PING will fail if we try # to check the health prior to the AUTH - self.send_command('AUTH', *auth_args, check_health=False) + self.send_command("AUTH", *auth_args, check_health=False) try: auth_response = self.read_response() @@ -672,23 +694,23 @@ def on_connect(self): # server seems to be < 6.0.0 which expects a single password # arg. retry auth with just the password. # https://github.com/andymccurdy/redis-py/issues/1274 - self.send_command('AUTH', self.password, check_health=False) + self.send_command("AUTH", self.password, check_health=False) auth_response = self.read_response() - if str_if_bytes(auth_response) != 'OK': - raise AuthenticationError('Invalid Username or Password') + if str_if_bytes(auth_response) != "OK": + raise AuthenticationError("Invalid Username or Password") # if a client_name is given, set it if self.client_name: - self.send_command('CLIENT', 'SETNAME', self.client_name) - if str_if_bytes(self.read_response()) != 'OK': - raise ConnectionError('Error setting client name') + self.send_command("CLIENT", "SETNAME", self.client_name) + if str_if_bytes(self.read_response()) != "OK": + raise ConnectionError("Error setting client name") # if a database is specified, switch to it if self.db: - self.send_command('SELECT', self.db) - if str_if_bytes(self.read_response()) != 'OK': - raise ConnectionError('Invalid Database') + self.send_command("SELECT", self.db) + if str_if_bytes(self.read_response()) != "OK": + raise ConnectionError("Invalid Database") def disconnect(self): "Disconnects from the Redis server" @@ -705,9 +727,9 @@ def disconnect(self): def _send_ping(self): """Send PING, expect PONG in return""" - self.send_command('PING', check_health=False) - if str_if_bytes(self.read_response()) != 'PONG': - raise ConnectionError('Bad response from PING health check') + self.send_command("PING", check_health=False) + if str_if_bytes(self.read_response()) != "PONG": + raise ConnectionError("Bad response from PING health check") def _ping_failed(self, error): """Function to call when PING fails""" @@ -736,7 +758,7 @@ def send_packed_command(self, command, check_health=True): except OSError as e: self.disconnect() if len(e.args) == 1: - errno, errmsg = 'UNKNOWN', e.args[0] + errno, errmsg = "UNKNOWN", e.args[0] else: errno = e.args[0] errmsg = e.args[1] @@ -747,8 +769,9 @@ def send_packed_command(self, command, check_health=True): def send_command(self, *args, **kwargs): """Pack and send a command to the Redis server""" - self.send_packed_command(self.pack_command(*args), - check_health=kwargs.get('check_health', True)) + self.send_packed_command( + self.pack_command(*args), check_health=kwargs.get("check_health", True) + ) def can_read(self, timeout=0): """Poll the socket to see if there's data that can be read.""" @@ -760,17 +783,15 @@ def can_read(self, timeout=0): def read_response(self, disable_decoding=False): """Read the response from a previously sent command""" try: - response = self._parser.read_response( - disable_decoding=disable_decoding - ) + response = self._parser.read_response(disable_decoding=disable_decoding) except socket.timeout: self.disconnect() raise TimeoutError(f"Timeout reading from {self.host}:{self.port}") except OSError as e: self.disconnect() raise ConnectionError( - f"Error while reading from {self.host}:{self.port}" - f" : {e.args}") + f"Error while reading from {self.host}:{self.port}" f" : {e.args}" + ) except BaseException: self.disconnect() raise @@ -792,7 +813,7 @@ def pack_command(self, *args): # not encoded. if isinstance(args[0], str): args = tuple(args[0].encode().split()) + args[1:] - elif b' ' in args[0]: + elif b" " in args[0]: args = tuple(args[0].split()) + args[1:] buff = SYM_EMPTY.join((SYM_STAR, str(len(args)).encode(), SYM_CRLF)) @@ -802,17 +823,28 @@ def pack_command(self, *args): # to avoid large string mallocs, chunk the command into the # output list if we're sending large values or memoryviews arg_length = len(arg) - if (len(buff) > buffer_cutoff or arg_length > buffer_cutoff - or isinstance(arg, memoryview)): + if ( + len(buff) > buffer_cutoff + or arg_length > buffer_cutoff + or isinstance(arg, memoryview) + ): buff = SYM_EMPTY.join( - (buff, SYM_DOLLAR, str(arg_length).encode(), SYM_CRLF)) + (buff, SYM_DOLLAR, str(arg_length).encode(), SYM_CRLF) + ) output.append(buff) output.append(arg) buff = SYM_CRLF else: buff = SYM_EMPTY.join( - (buff, SYM_DOLLAR, str(arg_length).encode(), - SYM_CRLF, arg, SYM_CRLF)) + ( + buff, + SYM_DOLLAR, + str(arg_length).encode(), + SYM_CRLF, + arg, + SYM_CRLF, + ) + ) output.append(buff) return output @@ -826,8 +858,11 @@ def pack_commands(self, commands): for cmd in commands: for chunk in self.pack_command(*cmd): chunklen = len(chunk) - if (buffer_length > buffer_cutoff or chunklen > buffer_cutoff - or isinstance(chunk, memoryview)): + if ( + buffer_length > buffer_cutoff + or chunklen > buffer_cutoff + or isinstance(chunk, memoryview) + ): output.append(SYM_EMPTY.join(pieces)) buffer_length = 0 pieces = [] @@ -844,10 +879,15 @@ def pack_commands(self, commands): class SSLConnection(Connection): - - def __init__(self, ssl_keyfile=None, ssl_certfile=None, - ssl_cert_reqs='required', ssl_ca_certs=None, - ssl_check_hostname=False, **kwargs): + def __init__( + self, + ssl_keyfile=None, + ssl_certfile=None, + ssl_cert_reqs="required", + ssl_ca_certs=None, + ssl_check_hostname=False, + **kwargs, + ): if not ssl_available: raise RedisError("Python wasn't built with SSL support") @@ -859,13 +899,14 @@ def __init__(self, ssl_keyfile=None, ssl_certfile=None, ssl_cert_reqs = ssl.CERT_NONE elif isinstance(ssl_cert_reqs, str): CERT_REQS = { - 'none': ssl.CERT_NONE, - 'optional': ssl.CERT_OPTIONAL, - 'required': ssl.CERT_REQUIRED + "none": ssl.CERT_NONE, + "optional": ssl.CERT_OPTIONAL, + "required": ssl.CERT_REQUIRED, } if ssl_cert_reqs not in CERT_REQS: raise RedisError( - f"Invalid SSL Certificate Requirements Flag: {ssl_cert_reqs}") + f"Invalid SSL Certificate Requirements Flag: {ssl_cert_reqs}" + ) ssl_cert_reqs = CERT_REQS[ssl_cert_reqs] self.cert_reqs = ssl_cert_reqs self.ca_certs = ssl_ca_certs @@ -878,22 +919,30 @@ def _connect(self): context.check_hostname = self.check_hostname context.verify_mode = self.cert_reqs if self.certfile and self.keyfile: - context.load_cert_chain(certfile=self.certfile, - keyfile=self.keyfile) + context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) if self.ca_certs: context.load_verify_locations(self.ca_certs) return context.wrap_socket(sock, server_hostname=self.host) class UnixDomainSocketConnection(Connection): - - def __init__(self, path='', db=0, username=None, password=None, - socket_timeout=None, encoding='utf-8', - encoding_errors='strict', decode_responses=False, - retry_on_timeout=False, - parser_class=DefaultParser, socket_read_size=65536, - health_check_interval=0, client_name=None, - retry=None): + def __init__( + self, + path="", + db=0, + username=None, + password=None, + socket_timeout=None, + encoding="utf-8", + encoding_errors="strict", + decode_responses=False, + retry_on_timeout=False, + parser_class=DefaultParser, + socket_read_size=65536, + health_check_interval=0, + client_name=None, + retry=None, + ): """ Initialize a new UnixDomainSocketConnection. To specify a retry policy, first set `retry_on_timeout` to `True` @@ -926,11 +975,11 @@ def __init__(self, path='', db=0, username=None, password=None, def repr_pieces(self): pieces = [ - ('path', self.path), - ('db', self.db), + ("path", self.path), + ("db", self.db), ] if self.client_name: - pieces.append(('client_name', self.client_name)) + pieces.append(("client_name", self.client_name)) return pieces def _connect(self): @@ -952,11 +1001,11 @@ def _error_message(self, exception): ) -FALSE_STRINGS = ('0', 'F', 'FALSE', 'N', 'NO') +FALSE_STRINGS = ("0", "F", "FALSE", "N", "NO") def to_bool(value): - if value is None or value == '': + if value is None or value == "": return None if isinstance(value, str) and value.upper() in FALSE_STRINGS: return False @@ -964,14 +1013,14 @@ def to_bool(value): URL_QUERY_ARGUMENT_PARSERS = { - 'db': int, - 'socket_timeout': float, - 'socket_connect_timeout': float, - 'socket_keepalive': to_bool, - 'retry_on_timeout': to_bool, - 'max_connections': int, - 'health_check_interval': int, - 'ssl_check_hostname': to_bool, + "db": int, + "socket_timeout": float, + "socket_connect_timeout": float, + "socket_keepalive": to_bool, + "retry_on_timeout": to_bool, + "max_connections": int, + "health_check_interval": int, + "ssl_check_hostname": to_bool, } @@ -987,42 +1036,42 @@ def parse_url(url): try: kwargs[name] = parser(value) except (TypeError, ValueError): - raise ValueError( - f"Invalid value for `{name}` in connection URL." - ) + raise ValueError(f"Invalid value for `{name}` in connection URL.") else: kwargs[name] = value if url.username: - kwargs['username'] = unquote(url.username) + kwargs["username"] = unquote(url.username) if url.password: - kwargs['password'] = unquote(url.password) + kwargs["password"] = unquote(url.password) # We only support redis://, rediss:// and unix:// schemes. - if url.scheme == 'unix': + if url.scheme == "unix": if url.path: - kwargs['path'] = unquote(url.path) - kwargs['connection_class'] = UnixDomainSocketConnection + kwargs["path"] = unquote(url.path) + kwargs["connection_class"] = UnixDomainSocketConnection - elif url.scheme in ('redis', 'rediss'): + elif url.scheme in ("redis", "rediss"): if url.hostname: - kwargs['host'] = unquote(url.hostname) + kwargs["host"] = unquote(url.hostname) if url.port: - kwargs['port'] = int(url.port) + kwargs["port"] = int(url.port) # If there's a path argument, use it as the db argument if a # querystring value wasn't specified - if url.path and 'db' not in kwargs: + if url.path and "db" not in kwargs: try: - kwargs['db'] = int(unquote(url.path).replace('/', '')) + kwargs["db"] = int(unquote(url.path).replace("/", "")) except (AttributeError, ValueError): pass - if url.scheme == 'rediss': - kwargs['connection_class'] = SSLConnection + if url.scheme == "rediss": + kwargs["connection_class"] = SSLConnection else: - raise ValueError('Redis URL must specify one of the following ' - 'schemes (redis://, rediss://, unix://)') + raise ValueError( + "Redis URL must specify one of the following " + "schemes (redis://, rediss://, unix://)" + ) return kwargs @@ -1040,6 +1089,7 @@ class ConnectionPool: Any additional keyword arguments are passed to the constructor of ``connection_class``. """ + @classmethod def from_url(cls, url, **kwargs): """ @@ -1084,8 +1134,9 @@ class initializer. In the case of conflicting arguments, querystring kwargs.update(url_options) return cls(**kwargs) - def __init__(self, connection_class=Connection, max_connections=None, - **connection_kwargs): + def __init__( + self, connection_class=Connection, max_connections=None, **connection_kwargs + ): max_connections = max_connections or 2 ** 31 if not isinstance(max_connections, int) or max_connections < 0: raise ValueError('"max_connections" must be a positive integer') @@ -1194,12 +1245,12 @@ def get_connection(self, command_name, *keys, **options): # closed. either way, reconnect and verify everything is good. try: if connection.can_read(): - raise ConnectionError('Connection has data') + raise ConnectionError("Connection has data") except ConnectionError: connection.disconnect() connection.connect() if connection.can_read(): - raise ConnectionError('Connection not ready') + raise ConnectionError("Connection not ready") except BaseException: # release the connection back to the pool so that we don't # leak it @@ -1212,9 +1263,9 @@ def get_encoder(self): "Return an encoder based on encoding settings" kwargs = self.connection_kwargs return Encoder( - encoding=kwargs.get('encoding', 'utf-8'), - encoding_errors=kwargs.get('encoding_errors', 'strict'), - decode_responses=kwargs.get('decode_responses', False) + encoding=kwargs.get("encoding", "utf-8"), + encoding_errors=kwargs.get("encoding_errors", "strict"), + decode_responses=kwargs.get("decode_responses", False), ) def make_connection(self): @@ -1259,8 +1310,9 @@ def disconnect(self, inuse_connections=True): self._checkpid() with self._lock: if inuse_connections: - connections = chain(self._available_connections, - self._in_use_connections) + connections = chain( + self._available_connections, self._in_use_connections + ) else: connections = self._available_connections @@ -1301,16 +1353,23 @@ class BlockingConnectionPool(ConnectionPool): >>> # not available. >>> pool = BlockingConnectionPool(timeout=5) """ - def __init__(self, max_connections=50, timeout=20, - connection_class=Connection, queue_class=LifoQueue, - **connection_kwargs): + + def __init__( + self, + max_connections=50, + timeout=20, + connection_class=Connection, + queue_class=LifoQueue, + **connection_kwargs, + ): self.queue_class = queue_class self.timeout = timeout super().__init__( connection_class=connection_class, max_connections=max_connections, - **connection_kwargs) + **connection_kwargs, + ) def reset(self): # Create and fill up a thread safe queue with ``None`` values. @@ -1381,12 +1440,12 @@ def get_connection(self, command_name, *keys, **options): # closed. either way, reconnect and verify everything is good. try: if connection.can_read(): - raise ConnectionError('Connection has data') + raise ConnectionError("Connection has data") except ConnectionError: connection.disconnect() connection.connect() if connection.can_read(): - raise ConnectionError('Connection not ready') + raise ConnectionError("Connection not ready") except BaseException: # release the connection back to the pool so that we don't leak it self.release(connection) diff --git a/redis/crc.py b/redis/crc.py index 7d2ee507be..c47e2acede 100644 --- a/redis/crc.py +++ b/redis/crc.py @@ -4,10 +4,7 @@ # For more information see: https://github.com/redis/redis/issues/2576 REDIS_CLUSTER_HASH_SLOTS = 16384 -__all__ = [ - "key_slot", - "REDIS_CLUSTER_HASH_SLOTS" -] +__all__ = ["key_slot", "REDIS_CLUSTER_HASH_SLOTS"] def key_slot(key, bucket=REDIS_CLUSTER_HASH_SLOTS): @@ -20,5 +17,5 @@ def key_slot(key, bucket=REDIS_CLUSTER_HASH_SLOTS): if start > -1: end = key.find(b"}", start + 1) if end > -1 and end != start + 1: - key = key[start + 1: end] + key = key[start + 1 : end] return crc_hqx(key, 0) % bucket diff --git a/redis/exceptions.py b/redis/exceptions.py index eb6ecc2dc5..e37cad358e 100644 --- a/redis/exceptions.py +++ b/redis/exceptions.py @@ -83,6 +83,7 @@ class AuthenticationWrongNumberOfArgsError(ResponseError): An error to indicate that the wrong number of args were sent to the AUTH command """ + pass @@ -90,6 +91,7 @@ class RedisClusterException(Exception): """ Base exception for the RedisCluster client """ + pass @@ -98,6 +100,7 @@ class ClusterError(RedisError): Cluster errors occurred multiple times, resulting in an exhaustion of the command execution TTL """ + pass @@ -111,6 +114,7 @@ class ClusterDownError(ClusterError, ResponseError): unavailable. It automatically returns available as soon as all the slots are covered again. """ + def __init__(self, resp): self.args = (resp,) self.message = resp @@ -135,8 +139,8 @@ def __init__(self, resp): """should only redirect to master node""" self.args = (resp,) self.message = resp - slot_id, new_node = resp.split(' ') - host, port = new_node.rsplit(':', 1) + slot_id, new_node = resp.split(" ") + host, port = new_node.rsplit(":", 1) self.slot_id = int(slot_id) self.node_addr = self.host, self.port = host, int(port) @@ -147,6 +151,7 @@ class TryAgainError(ResponseError): Operations on keys that don't exist or are - during resharding - split between the source and destination nodes, will generate a -TRYAGAIN error. """ + def __init__(self, *args, **kwargs): pass @@ -157,6 +162,7 @@ class ClusterCrossSlotError(ResponseError): A CROSSSLOT error is generated when keys in a request don't hash to the same slot. """ + message = "Keys in request don't hash to the same slot" @@ -166,6 +172,7 @@ class MovedError(AskError): A request sent to a node that doesn't serve this key will be replayed with a MOVED error that points to the correct node. """ + pass @@ -174,6 +181,7 @@ class MasterDownError(ClusterDownError): Error indicated MASTERDOWN error received from cluster. Link with MASTER is down and replica-serve-stale-data is set to 'no'. """ + pass @@ -185,4 +193,5 @@ class SlotNotCoveredError(RedisClusterException): If this error is raised the client should drop the current node layout and attempt to reconnect and refresh the node layout again """ + pass diff --git a/redis/lock.py b/redis/lock.py index d2297526a0..95bb413d7e 100644 --- a/redis/lock.py +++ b/redis/lock.py @@ -2,6 +2,7 @@ import time as mod_time import uuid from types import SimpleNamespace + from redis.exceptions import LockError, LockNotOwnedError @@ -70,8 +71,16 @@ class Lock: return 1 """ - def __init__(self, redis, name, timeout=None, sleep=0.1, - blocking=True, blocking_timeout=None, thread_local=True): + def __init__( + self, + redis, + name, + timeout=None, + sleep=0.1, + blocking=True, + blocking_timeout=None, + thread_local=True, + ): """ Create a new Lock instance named ``name`` using the Redis client supplied by ``redis``. @@ -129,11 +138,7 @@ def __init__(self, redis, name, timeout=None, sleep=0.1, self.blocking = blocking self.blocking_timeout = blocking_timeout self.thread_local = bool(thread_local) - self.local = ( - threading.local() - if self.thread_local - else SimpleNamespace() - ) + self.local = threading.local() if self.thread_local else SimpleNamespace() self.local.token = None self.register_scripts() @@ -145,8 +150,7 @@ def register_scripts(self): if cls.lua_extend is None: cls.lua_extend = client.register_script(cls.LUA_EXTEND_SCRIPT) if cls.lua_reacquire is None: - cls.lua_reacquire = \ - client.register_script(cls.LUA_REACQUIRE_SCRIPT) + cls.lua_reacquire = client.register_script(cls.LUA_REACQUIRE_SCRIPT) def __enter__(self): if self.acquire(): @@ -222,8 +226,7 @@ def owned(self): if stored_token and not isinstance(stored_token, bytes): encoder = self.redis.connection_pool.get_encoder() stored_token = encoder.encode(stored_token) - return self.local.token is not None and \ - stored_token == self.local.token + return self.local.token is not None and stored_token == self.local.token def release(self): "Releases the already acquired lock" @@ -234,11 +237,10 @@ def release(self): self.do_release(expected_token) def do_release(self, expected_token): - if not bool(self.lua_release(keys=[self.name], - args=[expected_token], - client=self.redis)): - raise LockNotOwnedError("Cannot release a lock" - " that's no longer owned") + if not bool( + self.lua_release(keys=[self.name], args=[expected_token], client=self.redis) + ): + raise LockNotOwnedError("Cannot release a lock" " that's no longer owned") def extend(self, additional_time, replace_ttl=False): """ @@ -262,17 +264,11 @@ def do_extend(self, additional_time, replace_ttl): if not bool( self.lua_extend( keys=[self.name], - args=[ - self.local.token, - additional_time, - replace_ttl and "1" or "0" - ], + args=[self.local.token, additional_time, replace_ttl and "1" or "0"], client=self.redis, ) ): - raise LockNotOwnedError( - "Cannot extend a lock that's" " no longer owned" - ) + raise LockNotOwnedError("Cannot extend a lock that's" " no longer owned") return True def reacquire(self): @@ -287,9 +283,10 @@ def reacquire(self): def do_reacquire(self): timeout = int(self.timeout * 1000) - if not bool(self.lua_reacquire(keys=[self.name], - args=[self.local.token, timeout], - client=self.redis)): - raise LockNotOwnedError("Cannot reacquire a lock that's" - " no longer owned") + if not bool( + self.lua_reacquire( + keys=[self.name], args=[self.local.token, timeout], client=self.redis + ) + ): + raise LockNotOwnedError("Cannot reacquire a lock that's" " no longer owned") return True diff --git a/redis/retry.py b/redis/retry.py index cd06a23e3d..75504c77e7 100644 --- a/redis/retry.py +++ b/redis/retry.py @@ -6,8 +6,9 @@ class Retry: """Retry a specific number of times after a failure""" - def __init__(self, backoff, retries, - supported_errors=(ConnectionError, TimeoutError)): + def __init__( + self, backoff, retries, supported_errors=(ConnectionError, TimeoutError) + ): """ Initialize a `Retry` object with a `Backoff` object that retries a maximum of `retries` times. diff --git a/redis/sentinel.py b/redis/sentinel.py index 06877bd167..c9383d30a9 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -3,9 +3,8 @@ from redis.client import Redis from redis.commands import SentinelCommands -from redis.connection import ConnectionPool, Connection, SSLConnection -from redis.exceptions import (ConnectionError, ResponseError, ReadOnlyError, - TimeoutError) +from redis.connection import Connection, ConnectionPool, SSLConnection +from redis.exceptions import ConnectionError, ReadOnlyError, ResponseError, TimeoutError from redis.utils import str_if_bytes @@ -19,14 +18,14 @@ class SlaveNotFoundError(ConnectionError): class SentinelManagedConnection(Connection): def __init__(self, **kwargs): - self.connection_pool = kwargs.pop('connection_pool') + self.connection_pool = kwargs.pop("connection_pool") super().__init__(**kwargs) def __repr__(self): pool = self.connection_pool - s = f'{type(self).__name__}' + s = f"{type(self).__name__}" if self.host: - host_info = f',host={self.host},port={self.port}' + host_info = f",host={self.host},port={self.port}" s = s % host_info return s @@ -34,9 +33,9 @@ def connect_to(self, address): self.host, self.port = address super().connect() if self.connection_pool.check_connection: - self.send_command('PING') - if str_if_bytes(self.read_response()) != 'PONG': - raise ConnectionError('PING failed') + self.send_command("PING") + if str_if_bytes(self.read_response()) != "PONG": + raise ConnectionError("PING failed") def connect(self): if self._sock: @@ -62,7 +61,7 @@ def read_response(self, disable_decoding=False): # calling disconnect will force the connection to re-query # sentinel during the next connect() attempt. self.disconnect() - raise ConnectionError('The previous master is now a slave') + raise ConnectionError("The previous master is now a slave") raise @@ -79,19 +78,21 @@ class SentinelConnectionPool(ConnectionPool): """ def __init__(self, service_name, sentinel_manager, **kwargs): - kwargs['connection_class'] = kwargs.get( - 'connection_class', - SentinelManagedSSLConnection if kwargs.pop('ssl', False) - else SentinelManagedConnection) - self.is_master = kwargs.pop('is_master', True) - self.check_connection = kwargs.pop('check_connection', False) + kwargs["connection_class"] = kwargs.get( + "connection_class", + SentinelManagedSSLConnection + if kwargs.pop("ssl", False) + else SentinelManagedConnection, + ) + self.is_master = kwargs.pop("is_master", True) + self.check_connection = kwargs.pop("check_connection", False) super().__init__(**kwargs) - self.connection_kwargs['connection_pool'] = weakref.proxy(self) + self.connection_kwargs["connection_pool"] = weakref.proxy(self) self.service_name = service_name self.sentinel_manager = sentinel_manager def __repr__(self): - role = 'master' if self.is_master else 'slave' + role = "master" if self.is_master else "slave" return f"{type(self).__name__}' def check_master_state(self, state, service_name): - if not state['is_master'] or state['is_sdown'] or state['is_odown']: + if not state["is_master"] or state["is_sdown"] or state["is_odown"]: return False # Check if our sentinel doesn't see other nodes - if state['num-other-sentinels'] < self.min_other_sentinels: + if state["num-other-sentinels"] < self.min_other_sentinels: return False return True @@ -232,17 +238,19 @@ def discover_master(self, service_name): if state and self.check_master_state(state, service_name): # Put this sentinel at the top of the list self.sentinels[0], self.sentinels[sentinel_no] = ( - sentinel, self.sentinels[0]) - return state['ip'], state['port'] + sentinel, + self.sentinels[0], + ) + return state["ip"], state["port"] raise MasterNotFoundError(f"No master found for {service_name!r}") def filter_slaves(self, slaves): "Remove slaves that are in an ODOWN or SDOWN state" slaves_alive = [] for slave in slaves: - if slave['is_odown'] or slave['is_sdown']: + if slave["is_odown"] or slave["is_sdown"]: continue - slaves_alive.append((slave['ip'], slave['port'])) + slaves_alive.append((slave["ip"], slave["port"])) return slaves_alive def discover_slaves(self, service_name): @@ -257,8 +265,13 @@ def discover_slaves(self, service_name): return slaves return [] - def master_for(self, service_name, redis_class=Redis, - connection_pool_class=SentinelConnectionPool, **kwargs): + def master_for( + self, + service_name, + redis_class=Redis, + connection_pool_class=SentinelConnectionPool, + **kwargs, + ): """ Returns a redis client instance for the ``service_name`` master. @@ -281,14 +294,22 @@ def master_for(self, service_name, redis_class=Redis, passed to this class and passed to the connection pool as keyword arguments to be used to initialize Redis connections. """ - kwargs['is_master'] = True + kwargs["is_master"] = True connection_kwargs = dict(self.connection_kwargs) connection_kwargs.update(kwargs) - return redis_class(connection_pool=connection_pool_class( - service_name, self, **connection_kwargs)) - - def slave_for(self, service_name, redis_class=Redis, - connection_pool_class=SentinelConnectionPool, **kwargs): + return redis_class( + connection_pool=connection_pool_class( + service_name, self, **connection_kwargs + ) + ) + + def slave_for( + self, + service_name, + redis_class=Redis, + connection_pool_class=SentinelConnectionPool, + **kwargs, + ): """ Returns redis client instance for the ``service_name`` slave(s). @@ -306,8 +327,11 @@ def slave_for(self, service_name, redis_class=Redis, passed to this class and passed to the connection pool as keyword arguments to be used to initialize Redis connections. """ - kwargs['is_master'] = False + kwargs["is_master"] = False connection_kwargs = dict(self.connection_kwargs) connection_kwargs.update(kwargs) - return redis_class(connection_pool=connection_pool_class( - service_name, self, **connection_kwargs)) + return redis_class( + connection_pool=connection_pool_class( + service_name, self, **connection_kwargs + ) + ) diff --git a/redis/utils.py b/redis/utils.py index 0e78cc5f3b..50961cb767 100644 --- a/redis/utils.py +++ b/redis/utils.py @@ -1,8 +1,8 @@ from contextlib import contextmanager - try: import hiredis # noqa + HIREDIS_AVAILABLE = True except ImportError: HIREDIS_AVAILABLE = False @@ -16,6 +16,7 @@ def from_url(url, **kwargs): none is provided. """ from redis.client import Redis + return Redis.from_url(url, **kwargs) @@ -28,9 +29,7 @@ def pipeline(redis_obj): def str_if_bytes(value): return ( - value.decode('utf-8', errors='replace') - if isinstance(value, bytes) - else value + value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value ) diff --git a/setup.py b/setup.py index 9acb501633..ee91298289 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ #!/usr/bin/env python -from setuptools import setup, find_packages +from setuptools import find_packages, setup + import redis setup( @@ -24,8 +25,8 @@ author_email="oss@redis.com", python_requires=">=3.6", install_requires=[ - 'deprecated==1.2.3', - 'packaging==21.3', + "deprecated==1.2.3", + "packaging==21.3", ], classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tasks.py b/tasks.py index 8d9c4c64be..9291e7effb 100644 --- a/tasks.py +++ b/tasks.py @@ -1,11 +1,11 @@ import os import shutil -from invoke import task, run -with open('tox.ini') as fp: +from invoke import run, task + +with open("tox.ini") as fp: lines = fp.read().split("\n") - dockers = [line.split("=")[1].strip() for line in lines - if line.find("name") != -1] + dockers = [line.split("=")[1].strip() for line in lines if line.find("name") != -1] @task @@ -14,7 +14,7 @@ def devenv(c): specified in the tox.ini file. """ clean(c) - cmd = 'tox -e devenv' + cmd = "tox -e devenv" for d in dockers: cmd += f" --docker-dont-stop={d}" run(cmd) diff --git a/tests/conftest.py b/tests/conftest.py index 8ed39abddc..24783c0466 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,16 @@ -from redis.backoff import NoBackoff -from redis.retry import Retry -import pytest import random -import redis import time from distutils.version import LooseVersion -from redis.connection import parse_url -from redis.exceptions import RedisClusterException from unittest.mock import Mock from urllib.parse import urlparse +import pytest + +import redis +from redis.backoff import NoBackoff +from redis.connection import parse_url +from redis.exceptions import RedisClusterException +from redis.retry import Retry REDIS_INFO = {} default_redis_url = "redis://localhost:6379/9" @@ -19,29 +20,37 @@ def pytest_addoption(parser): - parser.addoption('--redis-url', default=default_redis_url, - action="store", - help="Redis connection string," - " defaults to `%(default)s`") - - parser.addoption('--redismod-url', default=default_redismod_url, - action="store", - help="Connection string to redis server" - " with loaded modules," - " defaults to `%(default)s`") - - parser.addoption('--redis-cluster-nodes', default=default_cluster_nodes, - action="store", - help="The number of cluster nodes that need to be " - "available before the test can start," - " defaults to `%(default)s`") + parser.addoption( + "--redis-url", + default=default_redis_url, + action="store", + help="Redis connection string," " defaults to `%(default)s`", + ) + + parser.addoption( + "--redismod-url", + default=default_redismod_url, + action="store", + help="Connection string to redis server" + " with loaded modules," + " defaults to `%(default)s`", + ) + + parser.addoption( + "--redis-cluster-nodes", + default=default_cluster_nodes, + action="store", + help="The number of cluster nodes that need to be " + "available before the test can start," + " defaults to `%(default)s`", + ) def _get_info(redis_url): client = redis.Redis.from_url(redis_url) info = client.info() cmds = [command.upper() for command in client.command().keys()] - if 'dping' in cmds: + if "dping" in cmds: info["enterprise"] = True else: info["enterprise"] = False @@ -102,42 +111,39 @@ def wait_for_cluster_creation(redis_url, cluster_nodes, timeout=20): available_nodes = 0 if client is None else len(client.get_nodes()) raise RedisClusterException( f"The cluster did not become available after {timeout} seconds. " - f"Only {available_nodes} nodes out of {cluster_nodes} are available") + f"Only {available_nodes} nodes out of {cluster_nodes} are available" + ) def skip_if_server_version_lt(min_version): redis_version = REDIS_INFO["version"] check = LooseVersion(redis_version) < LooseVersion(min_version) - return pytest.mark.skipif( - check, - reason=f"Redis version required >= {min_version}") + return pytest.mark.skipif(check, reason=f"Redis version required >= {min_version}") def skip_if_server_version_gte(min_version): redis_version = REDIS_INFO["version"] check = LooseVersion(redis_version) >= LooseVersion(min_version) - return pytest.mark.skipif( - check, - reason=f"Redis version required < {min_version}") + return pytest.mark.skipif(check, reason=f"Redis version required < {min_version}") def skip_unless_arch_bits(arch_bits): - return pytest.mark.skipif(REDIS_INFO["arch_bits"] != arch_bits, - reason=f"server is not {arch_bits}-bit") + return pytest.mark.skipif( + REDIS_INFO["arch_bits"] != arch_bits, reason=f"server is not {arch_bits}-bit" + ) def skip_ifmodversion_lt(min_version: str, module_name: str): try: modules = REDIS_INFO["modules"] except KeyError: - return pytest.mark.skipif(True, - reason="Redis server does not have modules") + return pytest.mark.skipif(True, reason="Redis server does not have modules") if modules == []: return pytest.mark.skipif(True, reason="No redis modules found") for j in modules: - if module_name == j.get('name'): - version = j.get('ver') + if module_name == j.get("name"): + version = j.get("ver") mv = int(min_version.replace(".", "")) check = version < mv return pytest.mark.skipif(check, reason="Redis module version") @@ -155,9 +161,9 @@ def skip_ifnot_redis_enterprise(func): return pytest.mark.skipif(check, reason="Not running in redis enterprise") -def _get_client(cls, request, single_connection_client=True, flushdb=True, - from_url=None, - **kwargs): +def _get_client( + cls, request, single_connection_client=True, flushdb=True, from_url=None, **kwargs +): """ Helper for fixtures or tests that need a Redis client @@ -181,6 +187,7 @@ def _get_client(cls, request, single_connection_client=True, flushdb=True, if single_connection_client: client = client.client() if request: + def teardown(): if not cluster_mode: if flushdb: @@ -194,6 +201,7 @@ def teardown(): client.connection_pool.disconnect() else: cluster_teardown(client, flushdb) + request.addfinalizer(teardown) return client @@ -201,11 +209,11 @@ def teardown(): def cluster_teardown(client, flushdb): if flushdb: try: - client.flushdb(target_nodes='primaries') + client.flushdb(target_nodes="primaries") except redis.ConnectionError: # handle cases where a test disconnected a client # just manually retry the flushdb - client.flushdb(target_nodes='primaries') + client.flushdb(target_nodes="primaries") client.close() client.disconnect_connection_pools() @@ -214,9 +222,10 @@ def cluster_teardown(client, flushdb): # an index on db != 0 raises a ResponseError in redis @pytest.fixture() def modclient(request, **kwargs): - rmurl = request.config.getoption('--redismod-url') - with _get_client(redis.Redis, request, from_url=rmurl, - decode_responses=True, **kwargs) as client: + rmurl = request.config.getoption("--redismod-url") + with _get_client( + redis.Redis, request, from_url=rmurl, decode_responses=True, **kwargs + ) as client: yield client @@ -250,56 +259,61 @@ def _gen_cluster_mock_resp(r, response): @pytest.fixture() def mock_cluster_resp_ok(request, **kwargs): r = _get_client(redis.Redis, request, **kwargs) - return _gen_cluster_mock_resp(r, 'OK') + return _gen_cluster_mock_resp(r, "OK") @pytest.fixture() def mock_cluster_resp_int(request, **kwargs): r = _get_client(redis.Redis, request, **kwargs) - return _gen_cluster_mock_resp(r, '2') + return _gen_cluster_mock_resp(r, "2") @pytest.fixture() def mock_cluster_resp_info(request, **kwargs): r = _get_client(redis.Redis, request, **kwargs) - response = ('cluster_state:ok\r\ncluster_slots_assigned:16384\r\n' - 'cluster_slots_ok:16384\r\ncluster_slots_pfail:0\r\n' - 'cluster_slots_fail:0\r\ncluster_known_nodes:7\r\n' - 'cluster_size:3\r\ncluster_current_epoch:7\r\n' - 'cluster_my_epoch:2\r\ncluster_stats_messages_sent:170262\r\n' - 'cluster_stats_messages_received:105653\r\n') + response = ( + "cluster_state:ok\r\ncluster_slots_assigned:16384\r\n" + "cluster_slots_ok:16384\r\ncluster_slots_pfail:0\r\n" + "cluster_slots_fail:0\r\ncluster_known_nodes:7\r\n" + "cluster_size:3\r\ncluster_current_epoch:7\r\n" + "cluster_my_epoch:2\r\ncluster_stats_messages_sent:170262\r\n" + "cluster_stats_messages_received:105653\r\n" + ) return _gen_cluster_mock_resp(r, response) @pytest.fixture() def mock_cluster_resp_nodes(request, **kwargs): r = _get_client(redis.Redis, request, **kwargs) - response = ('c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 ' - 'slave aa90da731f673a99617dfe930306549a09f83a6b 0 ' - '1447836263059 5 connected\n' - '9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 ' - 'master - 0 1447836264065 0 connected\n' - 'aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 ' - 'myself,master - 0 0 2 connected 5461-10922\n' - '1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 ' - 'slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 ' - '1447836262556 3 connected\n' - '4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 ' - 'master - 0 1447836262555 7 connected 0-5460\n' - '19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 ' - 'master - 0 1447836263562 3 connected 10923-16383\n' - 'fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 ' - 'master,fail - 1447829446956 1447829444948 1 disconnected\n' - ) + response = ( + "c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 " + "slave aa90da731f673a99617dfe930306549a09f83a6b 0 " + "1447836263059 5 connected\n" + "9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 " + "master - 0 1447836264065 0 connected\n" + "aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 " + "myself,master - 0 0 2 connected 5461-10922\n" + "1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 " + "slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 " + "1447836262556 3 connected\n" + "4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 " + "master - 0 1447836262555 7 connected 0-5460\n" + "19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 " + "master - 0 1447836263562 3 connected 10923-16383\n" + "fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 " + "master,fail - 1447829446956 1447829444948 1 disconnected\n" + ) return _gen_cluster_mock_resp(r, response) @pytest.fixture() def mock_cluster_resp_slaves(request, **kwargs): r = _get_client(redis.Redis, request, **kwargs) - response = ("['1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 " - "slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 " - "1447836789290 3 connected']") + response = ( + "['1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 " + "slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 " + "1447836789290 3 connected']" + ) return _gen_cluster_mock_resp(r, response) @@ -315,15 +329,15 @@ def wait_for_command(client, monitor, command): # if we find a command with our key before the command we're waiting # for, something went wrong redis_version = REDIS_INFO["version"] - if LooseVersion(redis_version) >= LooseVersion('5.0.0'): + if LooseVersion(redis_version) >= LooseVersion("5.0.0"): id_str = str(client.client_id()) else: - id_str = f'{random.randrange(2 ** 32):08x}' - key = f'__REDIS-PY-{id_str}__' + id_str = f"{random.randrange(2 ** 32):08x}" + key = f"__REDIS-PY-{id_str}__" client.get(key) while True: monitor_response = monitor.next_command() - if command in monitor_response['command']: + if command in monitor_response["command"]: return monitor_response - if key in monitor_response['command']: + if key in monitor_response["command"]: return None diff --git a/tests/test_cluster.py b/tests/test_cluster.py index d12e47ed02..84d74bd43b 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -1,46 +1,47 @@ import binascii import datetime -import pytest import warnings - from time import sleep -from tests.test_pubsub import wait_for_message -from unittest.mock import call, patch, DEFAULT, Mock +from unittest.mock import DEFAULT, Mock, call, patch + +import pytest + from redis import Redis -from redis.cluster import get_node_name, ClusterNode, \ - RedisCluster, NodesManager, PRIMARY, REDIS_CLUSTER_HASH_SLOTS, REPLICA +from redis.cluster import ( + PRIMARY, + REDIS_CLUSTER_HASH_SLOTS, + REPLICA, + ClusterNode, + NodesManager, + RedisCluster, + get_node_name, +) from redis.commands import CommandsParser from redis.connection import Connection -from redis.utils import str_if_bytes +from redis.crc import key_slot from redis.exceptions import ( AskError, ClusterDownError, DataError, MovedError, RedisClusterException, - RedisError + RedisError, ) +from redis.utils import str_if_bytes +from tests.test_pubsub import wait_for_message -from redis.crc import key_slot -from .conftest import ( - _get_client, - skip_if_server_version_lt, - skip_unless_arch_bits -) +from .conftest import _get_client, skip_if_server_version_lt, skip_unless_arch_bits default_host = "127.0.0.1" default_port = 7000 default_cluster_slots = [ [ - 0, 8191, - ['127.0.0.1', 7000, 'node_0'], - ['127.0.0.1', 7003, 'node_3'], + 0, + 8191, + ["127.0.0.1", 7000, "node_0"], + ["127.0.0.1", 7003, "node_3"], ], - [ - 8192, 16383, - ['127.0.0.1', 7001, 'node_1'], - ['127.0.0.1', 7002, 'node_2'] - ] + [8192, 16383, ["127.0.0.1", 7001, "node_1"], ["127.0.0.1", 7002, "node_2"]], ] @@ -53,21 +54,20 @@ def slowlog(request, r): to test it """ # Save old values - current_config = r.config_get( - target_nodes=r.get_primaries()[0]) - old_slower_than_value = current_config['slowlog-log-slower-than'] - old_max_legnth_value = current_config['slowlog-max-len'] + current_config = r.config_get(target_nodes=r.get_primaries()[0]) + old_slower_than_value = current_config["slowlog-log-slower-than"] + old_max_legnth_value = current_config["slowlog-max-len"] # Function to restore the old values def cleanup(): - r.config_set('slowlog-log-slower-than', old_slower_than_value) - r.config_set('slowlog-max-len', old_max_legnth_value) + r.config_set("slowlog-log-slower-than", old_slower_than_value) + r.config_set("slowlog-max-len", old_max_legnth_value) request.addfinalizer(cleanup) # Set the new values - r.config_set('slowlog-log-slower-than', 0) - r.config_set('slowlog-max-len', 128) + r.config_set("slowlog-log-slower-than", 0) + r.config_set("slowlog-max-len", 128) def get_mocked_redis_client(func=None, *args, **kwargs): @@ -76,17 +76,18 @@ def get_mocked_redis_client(func=None, *args, **kwargs): nodes and slots setup to remove the problem of different IP addresses on different installations and machines. """ - cluster_slots = kwargs.pop('cluster_slots', default_cluster_slots) - coverage_res = kwargs.pop('coverage_result', 'yes') - with patch.object(Redis, 'execute_command') as execute_command_mock: + cluster_slots = kwargs.pop("cluster_slots", default_cluster_slots) + coverage_res = kwargs.pop("coverage_result", "yes") + with patch.object(Redis, "execute_command") as execute_command_mock: + def execute_command(*_args, **_kwargs): - if _args[0] == 'CLUSTER SLOTS': + if _args[0] == "CLUSTER SLOTS": mock_cluster_slots = cluster_slots return mock_cluster_slots - elif _args[0] == 'COMMAND': - return {'get': [], 'set': []} - elif _args[1] == 'cluster-require-full-coverage': - return {'cluster-require-full-coverage': coverage_res} + elif _args[0] == "COMMAND": + return {"get": [], "set": []} + elif _args[1] == "cluster-require-full-coverage": + return {"cluster-require-full-coverage": coverage_res} elif func is not None: return func(*args, **kwargs) else: @@ -94,16 +95,21 @@ def execute_command(*_args, **_kwargs): execute_command_mock.side_effect = execute_command - with patch.object(CommandsParser, 'initialize', - autospec=True) as cmd_parser_initialize: + with patch.object( + CommandsParser, "initialize", autospec=True + ) as cmd_parser_initialize: def cmd_init_mock(self, r): - self.commands = {'get': {'name': 'get', 'arity': 2, - 'flags': ['readonly', - 'fast'], - 'first_key_pos': 1, - 'last_key_pos': 1, - 'step_count': 1}} + self.commands = { + "get": { + "name": "get", + "arity": 2, + "flags": ["readonly", "fast"], + "first_key_pos": 1, + "last_key_pos": 1, + "step_count": 1, + } + } cmd_parser_initialize.side_effect = cmd_init_mock @@ -138,21 +144,21 @@ def find_node_ip_based_on_port(cluster_client, port): def moved_redirection_helper(request, failover=False): """ - Test that the client handles MOVED response after a failover. - Redirection after a failover means that the redirection address is of a - replica that was promoted to a primary. + Test that the client handles MOVED response after a failover. + Redirection after a failover means that the redirection address is of a + replica that was promoted to a primary. - At first call it should return a MOVED ResponseError that will point - the client to the next server it should talk to. + At first call it should return a MOVED ResponseError that will point + the client to the next server it should talk to. - Verify that: - 1. it tries to talk to the redirected node - 2. it updates the slot's primary to the redirected node + Verify that: + 1. it tries to talk to the redirected node + 2. it updates the slot's primary to the redirected node - For a failover, also verify: - 3. the redirected node's server type updated to 'primary' - 4. the server type of the previous slot owner updated to 'replica' - """ + For a failover, also verify: + 3. the redirected node's server type updated to 'primary' + 4. the server type of the previous slot owner updated to 'replica' + """ rc = _get_client(RedisCluster, request, flushdb=False) slot = 12182 redirect_node = None @@ -160,8 +166,7 @@ def moved_redirection_helper(request, failover=False): prev_primary = rc.nodes_manager.get_node_from_slot(slot) if failover: if len(rc.nodes_manager.slots_cache[slot]) < 2: - warnings.warn("Skipping this test since it requires to have a " - "replica") + warnings.warn("Skipping this test since it requires to have a " "replica") return redirect_node = rc.nodes_manager.slots_cache[slot][1] else: @@ -169,7 +174,8 @@ def moved_redirection_helper(request, failover=False): redirect_node = rc.get_primaries()[0] r_host = redirect_node.host r_port = redirect_node.port - with patch.object(Redis, 'parse_response') as parse_response: + with patch.object(Redis, "parse_response") as parse_response: + def moved_redirect_effect(connection, *args, **options): def ok_response(connection, *args, **options): assert connection.host == r_host @@ -201,8 +207,7 @@ def test_host_port_startup_node(self): args """ cluster = get_mocked_redis_client(host=default_host, port=default_port) - assert cluster.get_node(host=default_host, - port=default_port) is not None + assert cluster.get_node(host=default_host, port=default_port) is not None def test_startup_nodes(self): """ @@ -211,11 +216,15 @@ def test_startup_nodes(self): """ port_1 = 7000 port_2 = 7001 - startup_nodes = [ClusterNode(default_host, port_1), - ClusterNode(default_host, port_2)] + startup_nodes = [ + ClusterNode(default_host, port_1), + ClusterNode(default_host, port_2), + ] cluster = get_mocked_redis_client(startup_nodes=startup_nodes) - assert cluster.get_node(host=default_host, port=port_1) is not None \ - and cluster.get_node(host=default_host, port=port_2) is not None + assert ( + cluster.get_node(host=default_host, port=port_1) is not None + and cluster.get_node(host=default_host, port=port_2) is not None + ) def test_empty_startup_nodes(self): """ @@ -225,19 +234,19 @@ def test_empty_startup_nodes(self): RedisCluster(startup_nodes=[]) assert str(ex.value).startswith( - "RedisCluster requires at least one node to discover the " - "cluster"), str_if_bytes(ex.value) + "RedisCluster requires at least one node to discover the " "cluster" + ), str_if_bytes(ex.value) def test_from_url(self, r): redis_url = f"redis://{default_host}:{default_port}/0" - with patch.object(RedisCluster, 'from_url') as from_url: + with patch.object(RedisCluster, "from_url") as from_url: + def from_url_mocked(_url, **_kwargs): return get_mocked_redis_client(url=_url, **_kwargs) from_url.side_effect = from_url_mocked cluster = RedisCluster.from_url(redis_url) - assert cluster.get_node(host=default_host, - port=default_port) is not None + assert cluster.get_node(host=default_host, port=default_port) is not None def test_execute_command_errors(self, r): """ @@ -245,8 +254,9 @@ def test_execute_command_errors(self, r): """ with pytest.raises(RedisClusterException) as ex: r.execute_command("GET") - assert str(ex.value).startswith("No way to dispatch this command to " - "Redis Cluster. Missing key.") + assert str(ex.value).startswith( + "No way to dispatch this command to " "Redis Cluster. Missing key." + ) def test_execute_command_node_flag_primaries(self, r): """ @@ -254,7 +264,7 @@ def test_execute_command_node_flag_primaries(self, r): """ primaries = r.get_primaries() replicas = r.get_replicas() - mock_all_nodes_resp(r, 'PONG') + mock_all_nodes_resp(r, "PONG") assert r.ping(RedisCluster.PRIMARIES) is True for primary in primaries: conn = primary.redis_connection.connection @@ -271,7 +281,7 @@ def test_execute_command_node_flag_replicas(self, r): if not replicas: r = get_mocked_redis_client(default_host, default_port) primaries = r.get_primaries() - mock_all_nodes_resp(r, 'PONG') + mock_all_nodes_resp(r, "PONG") assert r.ping(RedisCluster.REPLICAS) is True for replica in replicas: conn = replica.redis_connection.connection @@ -284,7 +294,7 @@ def test_execute_command_node_flag_all_nodes(self, r): """ Test command execution with nodes flag ALL_NODES """ - mock_all_nodes_resp(r, 'PONG') + mock_all_nodes_resp(r, "PONG") assert r.ping(RedisCluster.ALL_NODES) is True for node in r.get_nodes(): conn = node.redis_connection.connection @@ -294,7 +304,7 @@ def test_execute_command_node_flag_random(self, r): """ Test command execution with nodes flag RANDOM """ - mock_all_nodes_resp(r, 'PONG') + mock_all_nodes_resp(r, "PONG") assert r.ping(RedisCluster.RANDOM) is True called_count = 0 for node in r.get_nodes(): @@ -309,7 +319,7 @@ def test_execute_command_default_node(self, r): default node """ def_node = r.get_default_node() - mock_node_resp(def_node, 'PONG') + mock_node_resp(def_node, "PONG") assert r.ping() is True conn = def_node.redis_connection.connection assert conn.read_response.called @@ -324,7 +334,8 @@ def test_ask_redirection(self, r): Important thing to verify is that it tries to talk to the second node. """ redirect_node = r.get_nodes()[0] - with patch.object(Redis, 'parse_response') as parse_response: + with patch.object(Redis, "parse_response") as parse_response: + def ask_redirect_effect(connection, *args, **options): def ok_response(connection, *args, **options): assert connection.host == redirect_node.host @@ -356,26 +367,22 @@ def test_refresh_using_specific_nodes(self, request): Test making calls on specific nodes when the cluster has failed over to another node """ - node_7006 = ClusterNode(host=default_host, port=7006, - server_type=PRIMARY) - node_7007 = ClusterNode(host=default_host, port=7007, - server_type=PRIMARY) - with patch.object(Redis, 'parse_response') as parse_response: - with patch.object(NodesManager, 'initialize', autospec=True) as \ - initialize: - with patch.multiple(Connection, - send_command=DEFAULT, - connect=DEFAULT, - can_read=DEFAULT) as mocks: + node_7006 = ClusterNode(host=default_host, port=7006, server_type=PRIMARY) + node_7007 = ClusterNode(host=default_host, port=7007, server_type=PRIMARY) + with patch.object(Redis, "parse_response") as parse_response: + with patch.object(NodesManager, "initialize", autospec=True) as initialize: + with patch.multiple( + Connection, send_command=DEFAULT, connect=DEFAULT, can_read=DEFAULT + ) as mocks: # simulate 7006 as a failed node - def parse_response_mock(connection, command_name, - **options): + def parse_response_mock(connection, command_name, **options): if connection.port == 7006: parse_response.failed_calls += 1 raise ClusterDownError( - 'CLUSTERDOWN The cluster is ' - 'down. Use CLUSTER INFO for ' - 'more information') + "CLUSTERDOWN The cluster is " + "down. Use CLUSTER INFO for " + "more information" + ) elif connection.port == 7007: parse_response.successful_calls += 1 @@ -391,8 +398,7 @@ def initialize_mock(self): # After the first connection fails, a reinitialize # should follow the cluster to 7007 def map_7007(self): - self.nodes_cache = { - node_7007.name: node_7007} + self.nodes_cache = {node_7007.name: node_7007} self.default_node = node_7007 self.slots_cache = {} @@ -406,44 +412,52 @@ def map_7007(self): parse_response.successful_calls = 0 parse_response.failed_calls = 0 initialize.side_effect = initialize_mock - mocks['can_read'].return_value = False - mocks['send_command'].return_value = "MOCK_OK" - mocks['connect'].return_value = None - with patch.object(CommandsParser, 'initialize', - autospec=True) as cmd_parser_initialize: + mocks["can_read"].return_value = False + mocks["send_command"].return_value = "MOCK_OK" + mocks["connect"].return_value = None + with patch.object( + CommandsParser, "initialize", autospec=True + ) as cmd_parser_initialize: def cmd_init_mock(self, r): - self.commands = {'get': {'name': 'get', 'arity': 2, - 'flags': ['readonly', - 'fast'], - 'first_key_pos': 1, - 'last_key_pos': 1, - 'step_count': 1}} + self.commands = { + "get": { + "name": "get", + "arity": 2, + "flags": ["readonly", "fast"], + "first_key_pos": 1, + "last_key_pos": 1, + "step_count": 1, + } + } cmd_parser_initialize.side_effect = cmd_init_mock - rc = _get_client( - RedisCluster, request, flushdb=False) + rc = _get_client(RedisCluster, request, flushdb=False) assert len(rc.get_nodes()) == 1 - assert rc.get_node(node_name=node_7006.name) is not \ - None + assert rc.get_node(node_name=node_7006.name) is not None - rc.get('foo') + rc.get("foo") # Cluster should now point to 7007, and there should be # one failed and one successful call assert len(rc.get_nodes()) == 1 - assert rc.get_node(node_name=node_7007.name) is not \ - None + assert rc.get_node(node_name=node_7007.name) is not None assert rc.get_node(node_name=node_7006.name) is None assert parse_response.failed_calls == 1 assert parse_response.successful_calls == 1 def test_reading_from_replicas_in_round_robin(self): - with patch.multiple(Connection, send_command=DEFAULT, - read_response=DEFAULT, _connect=DEFAULT, - can_read=DEFAULT, on_connect=DEFAULT) as mocks: - with patch.object(Redis, 'parse_response') as parse_response: + with patch.multiple( + Connection, + send_command=DEFAULT, + read_response=DEFAULT, + _connect=DEFAULT, + can_read=DEFAULT, + on_connect=DEFAULT, + ) as mocks: + with patch.object(Redis, "parse_response") as parse_response: + def parse_response_mock_first(connection, *args, **options): # Primary assert connection.port == 7001 @@ -465,16 +479,16 @@ def parse_response_mock_third(connection, *args, **options): # do want RedisCluster.on_connect function to get called, # so we'll mock some of the Connection's functions to allow it parse_response.side_effect = parse_response_mock_first - mocks['send_command'].return_value = True - mocks['read_response'].return_value = "OK" - mocks['_connect'].return_value = True - mocks['can_read'].return_value = False - mocks['on_connect'].return_value = True + mocks["send_command"].return_value = True + mocks["read_response"].return_value = "OK" + mocks["_connect"].return_value = True + mocks["can_read"].return_value = False + mocks["on_connect"].return_value = True # Create a cluster with reading from replications - read_cluster = get_mocked_redis_client(host=default_host, - port=default_port, - read_from_replicas=True) + read_cluster = get_mocked_redis_client( + host=default_host, port=default_port, read_from_replicas=True + ) assert read_cluster.read_from_replicas is True # Check that we read from the slot's nodes in a round robin # matter. @@ -483,7 +497,7 @@ def parse_response_mock_third(connection, *args, **options): read_cluster.get("foo") read_cluster.get("foo") read_cluster.get("foo") - mocks['send_command'].assert_has_calls([call('READONLY')]) + mocks["send_command"].assert_has_calls([call("READONLY")]) def test_keyslot(self, r): """ @@ -503,8 +517,10 @@ def test_keyslot(self, r): assert r.keyslot(b"abc") == r.keyslot("abc") def test_get_node_name(self): - assert get_node_name(default_host, default_port) == \ - f"{default_host}:{default_port}" + assert ( + get_node_name(default_host, default_port) + == f"{default_host}:{default_port}" + ) def test_all_nodes(self, r): """ @@ -520,8 +536,11 @@ def test_all_nodes_masters(self, r): Set a list of nodes with random primaries/replicas config and it shold be possible to iterate over all of them. """ - nodes = [node for node in r.nodes_manager.nodes_cache.values() - if node.server_type == PRIMARY] + nodes = [ + node + for node in r.nodes_manager.nodes_cache.values() + if node.server_type == PRIMARY + ] for node in r.get_primaries(): assert node in nodes @@ -532,12 +551,14 @@ def test_cluster_down_overreaches_retry_attempts(self): command as many times as configured in cluster_error_retry_attempts and then raise the exception """ - with patch.object(RedisCluster, '_execute_command') as execute_command: + with patch.object(RedisCluster, "_execute_command") as execute_command: + def raise_cluster_down_error(target_node, *args, **kwargs): execute_command.failed_calls += 1 raise ClusterDownError( - 'CLUSTERDOWN The cluster is down. Use CLUSTER INFO for ' - 'more information') + "CLUSTERDOWN The cluster is down. Use CLUSTER INFO for " + "more information" + ) execute_command.side_effect = raise_cluster_down_error @@ -545,8 +566,7 @@ def raise_cluster_down_error(target_node, *args, **kwargs): with pytest.raises(ClusterDownError): rc.get("bar") - assert execute_command.failed_calls == \ - rc.cluster_error_retry_attempts + assert execute_command.failed_calls == rc.cluster_error_retry_attempts def test_connection_error_overreaches_retry_attempts(self): """ @@ -554,7 +574,8 @@ def test_connection_error_overreaches_retry_attempts(self): command as many times as configured in cluster_error_retry_attempts and then raise the exception """ - with patch.object(RedisCluster, '_execute_command') as execute_command: + with patch.object(RedisCluster, "_execute_command") as execute_command: + def raise_conn_error(target_node, *args, **kwargs): execute_command.failed_calls += 1 raise ConnectionError() @@ -565,8 +586,7 @@ def raise_conn_error(target_node, *args, **kwargs): with pytest.raises(ConnectionError): rc.get("bar") - assert execute_command.failed_calls == \ - rc.cluster_error_retry_attempts + assert execute_command.failed_calls == rc.cluster_error_retry_attempts def test_user_on_connect_function(self, request): """ @@ -600,7 +620,7 @@ def test_set_default_node_failure(self, r): test failed replacement of the default cluster node """ default_node = r.get_default_node() - new_def_node = ClusterNode('1.1.1.1', 1111) + new_def_node = ClusterNode("1.1.1.1", 1111) assert r.set_default_node(None) is False assert r.set_default_node(new_def_node) is False assert r.get_default_node() == default_node @@ -609,7 +629,7 @@ def test_get_node_from_key(self, r): """ Test that get_node_from_key function returns the correct node """ - key = 'bar' + key = "bar" slot = r.keyslot(key) slot_nodes = r.nodes_manager.slots_cache.get(slot) primary = slot_nodes[0] @@ -627,78 +647,79 @@ class TestClusterRedisCommands: """ def test_case_insensitive_command_names(self, r): - assert r.cluster_response_callbacks['cluster addslots'] == \ - r.cluster_response_callbacks['CLUSTER ADDSLOTS'] + assert ( + r.cluster_response_callbacks["cluster addslots"] + == r.cluster_response_callbacks["CLUSTER ADDSLOTS"] + ) def test_get_and_set(self, r): # get and set can't be tested independently of each other - assert r.get('a') is None - byte_string = b'value' + assert r.get("a") is None + byte_string = b"value" integer = 5 - unicode_string = chr(3456) + 'abcd' + chr(3421) - assert r.set('byte_string', byte_string) - assert r.set('integer', 5) - assert r.set('unicode_string', unicode_string) - assert r.get('byte_string') == byte_string - assert r.get('integer') == str(integer).encode() - assert r.get('unicode_string').decode('utf-8') == unicode_string + unicode_string = chr(3456) + "abcd" + chr(3421) + assert r.set("byte_string", byte_string) + assert r.set("integer", 5) + assert r.set("unicode_string", unicode_string) + assert r.get("byte_string") == byte_string + assert r.get("integer") == str(integer).encode() + assert r.get("unicode_string").decode("utf-8") == unicode_string def test_mget_nonatomic(self, r): assert r.mget_nonatomic([]) == [] - assert r.mget_nonatomic(['a', 'b']) == [None, None] - r['a'] = '1' - r['b'] = '2' - r['c'] = '3' + assert r.mget_nonatomic(["a", "b"]) == [None, None] + r["a"] = "1" + r["b"] = "2" + r["c"] = "3" - assert (r.mget_nonatomic('a', 'other', 'b', 'c') == - [b'1', None, b'2', b'3']) + assert r.mget_nonatomic("a", "other", "b", "c") == [b"1", None, b"2", b"3"] def test_mset_nonatomic(self, r): - d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + d = {"a": b"1", "b": b"2", "c": b"3", "d": b"4"} assert r.mset_nonatomic(d) for k, v in d.items(): assert r[k] == v def test_config_set(self, r): - assert r.config_set('slowlog-log-slower-than', 0) + assert r.config_set("slowlog-log-slower-than", 0) def test_cluster_config_resetstat(self, r): - r.ping(target_nodes='all') - all_info = r.info(target_nodes='all') + r.ping(target_nodes="all") + all_info = r.info(target_nodes="all") prior_commands_processed = -1 for node_info in all_info.values(): - prior_commands_processed = node_info['total_commands_processed'] + prior_commands_processed = node_info["total_commands_processed"] assert prior_commands_processed >= 1 - r.config_resetstat(target_nodes='all') - all_info = r.info(target_nodes='all') + r.config_resetstat(target_nodes="all") + all_info = r.info(target_nodes="all") for node_info in all_info.values(): - reset_commands_processed = node_info['total_commands_processed'] + reset_commands_processed = node_info["total_commands_processed"] assert reset_commands_processed < prior_commands_processed def test_client_setname(self, r): node = r.get_random_node() - r.client_setname('redis_py_test', target_nodes=node) + r.client_setname("redis_py_test", target_nodes=node) client_name = r.client_getname(target_nodes=node) - assert client_name == 'redis_py_test' + assert client_name == "redis_py_test" def test_exists(self, r): - d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + d = {"a": b"1", "b": b"2", "c": b"3", "d": b"4"} r.mset_nonatomic(d) assert r.exists(*d.keys()) == len(d) def test_delete(self, r): - d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + d = {"a": b"1", "b": b"2", "c": b"3", "d": b"4"} r.mset_nonatomic(d) assert r.delete(*d.keys()) == len(d) assert r.delete(*d.keys()) == 0 def test_touch(self, r): - d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + d = {"a": b"1", "b": b"2", "c": b"3", "d": b"4"} r.mset_nonatomic(d) assert r.touch(*d.keys()) == len(d) def test_unlink(self, r): - d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + d = {"a": b"1", "b": b"2", "c": b"3", "d": b"4"} r.mset_nonatomic(d) assert r.unlink(*d.keys()) == len(d) # Unlink is non-blocking so we sleep before @@ -718,7 +739,7 @@ def test_pubsub_channels_merge_results(self, r): p = r.pubsub(node) pubsub_nodes.append(p) p.subscribe(channel) - b_channel = channel.encode('utf-8') + b_channel = channel.encode("utf-8") channels.append(b_channel) # Assert that each node returns only the channel it subscribed to sub_channels = node.redis_connection.pubsub_channels() @@ -730,7 +751,7 @@ def test_pubsub_channels_merge_results(self, r): i += 1 # Assert that the cluster's pubsub_channels function returns ALL of # the cluster's channels - result = r.pubsub_channels(target_nodes='all') + result = r.pubsub_channels(target_nodes="all") result.sort() assert result == channels @@ -738,7 +759,7 @@ def test_pubsub_numsub_merge_results(self, r): nodes = r.get_nodes() pubsub_nodes = [] channel = "foo" - b_channel = channel.encode('utf-8') + b_channel = channel.encode("utf-8") for node in nodes: # We will create different pubsub clients where each one is # connected to a different node @@ -753,8 +774,7 @@ def test_pubsub_numsub_merge_results(self, r): assert sub_chann_num == [(b_channel, 1)] # Assert that the cluster's pubsub_numsub function returns ALL clients # subscribed to this channel in the entire cluster - assert r.pubsub_numsub(channel, target_nodes='all') == \ - [(b_channel, len(nodes))] + assert r.pubsub_numsub(channel, target_nodes="all") == [(b_channel, len(nodes))] def test_pubsub_numpat_merge_results(self, r): nodes = r.get_nodes() @@ -774,35 +794,35 @@ def test_pubsub_numpat_merge_results(self, r): assert sub_num_pat == 1 # Assert that the cluster's pubsub_numsub function returns ALL clients # subscribed to this channel in the entire cluster - assert r.pubsub_numpat(target_nodes='all') == len(nodes) + assert r.pubsub_numpat(target_nodes="all") == len(nodes) - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_cluster_pubsub_channels(self, r): p = r.pubsub() - p.subscribe('foo', 'bar', 'baz', 'quux') + p.subscribe("foo", "bar", "baz", "quux") for i in range(4): - assert wait_for_message(p, timeout=0.5)['type'] == 'subscribe' - expected = [b'bar', b'baz', b'foo', b'quux'] - assert all([channel in r.pubsub_channels(target_nodes='all') - for channel in expected]) + assert wait_for_message(p, timeout=0.5)["type"] == "subscribe" + expected = [b"bar", b"baz", b"foo", b"quux"] + assert all( + [channel in r.pubsub_channels(target_nodes="all") for channel in expected] + ) - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_cluster_pubsub_numsub(self, r): p1 = r.pubsub() - p1.subscribe('foo', 'bar', 'baz') + p1.subscribe("foo", "bar", "baz") for i in range(3): - assert wait_for_message(p1, timeout=0.5)['type'] == 'subscribe' + assert wait_for_message(p1, timeout=0.5)["type"] == "subscribe" p2 = r.pubsub() - p2.subscribe('bar', 'baz') + p2.subscribe("bar", "baz") for i in range(2): - assert wait_for_message(p2, timeout=0.5)['type'] == 'subscribe' + assert wait_for_message(p2, timeout=0.5)["type"] == "subscribe" p3 = r.pubsub() - p3.subscribe('baz') - assert wait_for_message(p3, timeout=0.5)['type'] == 'subscribe' + p3.subscribe("baz") + assert wait_for_message(p3, timeout=0.5)["type"] == "subscribe" - channels = [(b'foo', 1), (b'bar', 2), (b'baz', 3)] - assert r.pubsub_numsub('foo', 'bar', 'baz', target_nodes='all') \ - == channels + channels = [(b"foo", 1), (b"bar", 2), (b"baz", 3)] + assert r.pubsub_numsub("foo", "bar", "baz", target_nodes="all") == channels def test_cluster_slots(self, r): mock_all_nodes_resp(r, default_cluster_slots) @@ -810,12 +830,11 @@ def test_cluster_slots(self, r): assert isinstance(cluster_slots, dict) assert len(default_cluster_slots) == len(cluster_slots) assert cluster_slots.get((0, 8191)) is not None - assert cluster_slots.get((0, 8191)).get('primary') == \ - ('127.0.0.1', 7000) + assert cluster_slots.get((0, 8191)).get("primary") == ("127.0.0.1", 7000) def test_cluster_addslots(self, r): node = r.get_random_node() - mock_node_resp(node, 'OK') + mock_node_resp(node, "OK") assert r.cluster_addslots(node, 1, 2, 3) is True def test_cluster_countkeysinslot(self, r): @@ -825,22 +844,25 @@ def test_cluster_countkeysinslot(self, r): def test_cluster_count_failure_report(self, r): mock_all_nodes_resp(r, 0) - assert r.cluster_count_failure_report('node_0') == 0 + assert r.cluster_count_failure_report("node_0") == 0 def test_cluster_delslots(self): cluster_slots = [ [ - 0, 8191, - ['127.0.0.1', 7000, 'node_0'], + 0, + 8191, + ["127.0.0.1", 7000, "node_0"], ], [ - 8192, 16383, - ['127.0.0.1', 7001, 'node_1'], - ] + 8192, + 16383, + ["127.0.0.1", 7001, "node_1"], + ], ] - r = get_mocked_redis_client(host=default_host, port=default_port, - cluster_slots=cluster_slots) - mock_all_nodes_resp(r, 'OK') + r = get_mocked_redis_client( + host=default_host, port=default_port, cluster_slots=cluster_slots + ) + mock_all_nodes_resp(r, "OK") node0 = r.get_node(default_host, 7000) node1 = r.get_node(default_host, 7001) assert r.cluster_delslots(0, 8192) == [True, True] @@ -849,59 +871,61 @@ def test_cluster_delslots(self): def test_cluster_failover(self, r): node = r.get_random_node() - mock_node_resp(node, 'OK') + mock_node_resp(node, "OK") assert r.cluster_failover(node) is True - assert r.cluster_failover(node, 'FORCE') is True - assert r.cluster_failover(node, 'TAKEOVER') is True + assert r.cluster_failover(node, "FORCE") is True + assert r.cluster_failover(node, "TAKEOVER") is True with pytest.raises(RedisError): - r.cluster_failover(node, 'FORCT') + r.cluster_failover(node, "FORCT") def test_cluster_info(self, r): info = r.cluster_info() assert isinstance(info, dict) - assert info['cluster_state'] == 'ok' + assert info["cluster_state"] == "ok" def test_cluster_keyslot(self, r): mock_all_nodes_resp(r, 12182) - assert r.cluster_keyslot('foo') == 12182 + assert r.cluster_keyslot("foo") == 12182 def test_cluster_meet(self, r): node = r.get_default_node() - mock_node_resp(node, 'OK') - assert r.cluster_meet('127.0.0.1', 6379) is True + mock_node_resp(node, "OK") + assert r.cluster_meet("127.0.0.1", 6379) is True def test_cluster_nodes(self, r): response = ( - 'c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 ' - 'slave aa90da731f673a99617dfe930306549a09f83a6b 0 ' - '1447836263059 5 connected\n' - '9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 ' - 'master - 0 1447836264065 0 connected\n' - 'aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 ' - 'myself,master - 0 0 2 connected 5461-10922\n' - '1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 ' - 'slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 ' - '1447836262556 3 connected\n' - '4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 ' - 'master - 0 1447836262555 7 connected 0-5460\n' - '19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 ' - 'master - 0 1447836263562 3 connected 10923-16383\n' - 'fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 ' - 'master,fail - 1447829446956 1447829444948 1 disconnected\n' + "c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 " + "slave aa90da731f673a99617dfe930306549a09f83a6b 0 " + "1447836263059 5 connected\n" + "9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 " + "master - 0 1447836264065 0 connected\n" + "aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 " + "myself,master - 0 0 2 connected 5461-10922\n" + "1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 " + "slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 " + "1447836262556 3 connected\n" + "4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 " + "master - 0 1447836262555 7 connected 0-5460\n" + "19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 " + "master - 0 1447836263562 3 connected 10923-16383\n" + "fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 " + "master,fail - 1447829446956 1447829444948 1 disconnected\n" ) mock_all_nodes_resp(r, response) nodes = r.cluster_nodes() assert len(nodes) == 7 - assert nodes.get('172.17.0.7:7006') is not None - assert nodes.get('172.17.0.7:7006').get('node_id') == \ - "c8253bae761cb1ecb2b61857d85dfe455a0fec8b" + assert nodes.get("172.17.0.7:7006") is not None + assert ( + nodes.get("172.17.0.7:7006").get("node_id") + == "c8253bae761cb1ecb2b61857d85dfe455a0fec8b" + ) def test_cluster_replicate(self, r): node = r.get_random_node() all_replicas = r.get_replicas() - mock_all_nodes_resp(r, 'OK') - assert r.cluster_replicate(node, 'c8253bae761cb61857d') is True - results = r.cluster_replicate(all_replicas, 'c8253bae761cb61857d') + mock_all_nodes_resp(r, "OK") + assert r.cluster_replicate(node, "c8253bae761cb61857d") is True + results = r.cluster_replicate(all_replicas, "c8253bae761cb61857d") if isinstance(results, dict): for res in results.values(): assert res is True @@ -909,74 +933,78 @@ def test_cluster_replicate(self, r): assert results is True def test_cluster_reset(self, r): - mock_all_nodes_resp(r, 'OK') + mock_all_nodes_resp(r, "OK") assert r.cluster_reset() is True assert r.cluster_reset(False) is True - all_results = r.cluster_reset(False, target_nodes='all') + all_results = r.cluster_reset(False, target_nodes="all") for res in all_results.values(): assert res is True def test_cluster_save_config(self, r): node = r.get_random_node() all_nodes = r.get_nodes() - mock_all_nodes_resp(r, 'OK') + mock_all_nodes_resp(r, "OK") assert r.cluster_save_config(node) is True all_results = r.cluster_save_config(all_nodes) for res in all_results.values(): assert res is True def test_cluster_get_keys_in_slot(self, r): - response = [b'{foo}1', b'{foo}2'] + response = [b"{foo}1", b"{foo}2"] node = r.nodes_manager.get_node_from_slot(12182) mock_node_resp(node, response) keys = r.cluster_get_keys_in_slot(12182, 4) assert keys == response def test_cluster_set_config_epoch(self, r): - mock_all_nodes_resp(r, 'OK') + mock_all_nodes_resp(r, "OK") assert r.cluster_set_config_epoch(3) is True - all_results = r.cluster_set_config_epoch(3, target_nodes='all') + all_results = r.cluster_set_config_epoch(3, target_nodes="all") for res in all_results.values(): assert res is True def test_cluster_setslot(self, r): node = r.get_random_node() - mock_node_resp(node, 'OK') - assert r.cluster_setslot(node, 'node_0', 1218, 'IMPORTING') is True - assert r.cluster_setslot(node, 'node_0', 1218, 'NODE') is True - assert r.cluster_setslot(node, 'node_0', 1218, 'MIGRATING') is True + mock_node_resp(node, "OK") + assert r.cluster_setslot(node, "node_0", 1218, "IMPORTING") is True + assert r.cluster_setslot(node, "node_0", 1218, "NODE") is True + assert r.cluster_setslot(node, "node_0", 1218, "MIGRATING") is True with pytest.raises(RedisError): - r.cluster_failover(node, 'STABLE') + r.cluster_failover(node, "STABLE") with pytest.raises(RedisError): - r.cluster_failover(node, 'STATE') + r.cluster_failover(node, "STATE") def test_cluster_setslot_stable(self, r): node = r.nodes_manager.get_node_from_slot(12182) - mock_node_resp(node, 'OK') + mock_node_resp(node, "OK") assert r.cluster_setslot_stable(12182) is True assert node.redis_connection.connection.read_response.called def test_cluster_replicas(self, r): - response = [b'01eca22229cf3c652b6fca0d09ff6941e0d2e3 ' - b'127.0.0.1:6377@16377 slave ' - b'52611e796814b78e90ad94be9d769a4f668f9a 0 ' - b'1634550063436 4 connected', - b'r4xfga22229cf3c652b6fca0d09ff69f3e0d4d ' - b'127.0.0.1:6378@16378 slave ' - b'52611e796814b78e90ad94be9d769a4f668f9a 0 ' - b'1634550063436 4 connected'] + response = [ + b"01eca22229cf3c652b6fca0d09ff6941e0d2e3 " + b"127.0.0.1:6377@16377 slave " + b"52611e796814b78e90ad94be9d769a4f668f9a 0 " + b"1634550063436 4 connected", + b"r4xfga22229cf3c652b6fca0d09ff69f3e0d4d " + b"127.0.0.1:6378@16378 slave " + b"52611e796814b78e90ad94be9d769a4f668f9a 0 " + b"1634550063436 4 connected", + ] mock_all_nodes_resp(r, response) - replicas = r.cluster_replicas('52611e796814b78e90ad94be9d769a4f668f9a') - assert replicas.get('127.0.0.1:6377') is not None - assert replicas.get('127.0.0.1:6378') is not None - assert replicas.get('127.0.0.1:6378').get('node_id') == \ - 'r4xfga22229cf3c652b6fca0d09ff69f3e0d4d' + replicas = r.cluster_replicas("52611e796814b78e90ad94be9d769a4f668f9a") + assert replicas.get("127.0.0.1:6377") is not None + assert replicas.get("127.0.0.1:6378") is not None + assert ( + replicas.get("127.0.0.1:6378").get("node_id") + == "r4xfga22229cf3c652b6fca0d09ff69f3e0d4d" + ) def test_readonly(self): r = get_mocked_redis_client(host=default_host, port=default_port) - mock_all_nodes_resp(r, 'OK') + mock_all_nodes_resp(r, "OK") assert r.readonly() is True - all_replicas_results = r.readonly(target_nodes='replicas') + all_replicas_results = r.readonly(target_nodes="replicas") for res in all_replicas_results.values(): assert res is True for replica in r.get_replicas(): @@ -984,9 +1012,9 @@ def test_readonly(self): def test_readwrite(self): r = get_mocked_redis_client(host=default_host, port=default_port) - mock_all_nodes_resp(r, 'OK') + mock_all_nodes_resp(r, "OK") assert r.readwrite() is True - all_replicas_results = r.readwrite(target_nodes='replicas') + all_replicas_results = r.readwrite(target_nodes="replicas") for res in all_replicas_results.values(): assert res is True for replica in r.get_replicas(): @@ -999,59 +1027,59 @@ def test_bgsave(self, r): def test_info(self, r): # Map keys to same slot - r.set('x{1}', 1) - r.set('y{1}', 2) - r.set('z{1}', 3) + r.set("x{1}", 1) + r.set("y{1}", 2) + r.set("z{1}", 3) # Get node that handles the slot - slot = r.keyslot('x{1}') + slot = r.keyslot("x{1}") node = r.nodes_manager.get_node_from_slot(slot) # Run info on that node info = r.info(target_nodes=node) assert isinstance(info, dict) - assert info['db0']['keys'] == 3 + assert info["db0"]["keys"] == 3 def _init_slowlog_test(self, r, node): - slowlog_lim = r.config_get('slowlog-log-slower-than', - target_nodes=node) - assert r.config_set('slowlog-log-slower-than', 0, target_nodes=node) \ - is True - return slowlog_lim['slowlog-log-slower-than'] + slowlog_lim = r.config_get("slowlog-log-slower-than", target_nodes=node) + assert r.config_set("slowlog-log-slower-than", 0, target_nodes=node) is True + return slowlog_lim["slowlog-log-slower-than"] def _teardown_slowlog_test(self, r, node, prev_limit): - assert r.config_set('slowlog-log-slower-than', prev_limit, - target_nodes=node) is True + assert ( + r.config_set("slowlog-log-slower-than", prev_limit, target_nodes=node) + is True + ) def test_slowlog_get(self, r, slowlog): - unicode_string = chr(3456) + 'abcd' + chr(3421) + unicode_string = chr(3456) + "abcd" + chr(3421) node = r.get_node_from_key(unicode_string) slowlog_limit = self._init_slowlog_test(r, node) assert r.slowlog_reset(target_nodes=node) r.get(unicode_string) slowlog = r.slowlog_get(target_nodes=node) assert isinstance(slowlog, list) - commands = [log['command'] for log in slowlog] + commands = [log["command"] for log in slowlog] - get_command = b' '.join((b'GET', unicode_string.encode('utf-8'))) + get_command = b" ".join((b"GET", unicode_string.encode("utf-8"))) assert get_command in commands - assert b'SLOWLOG RESET' in commands + assert b"SLOWLOG RESET" in commands # the order should be ['GET ', 'SLOWLOG RESET'], # but if other clients are executing commands at the same time, there # could be commands, before, between, or after, so just check that # the two we care about are in the appropriate order. - assert commands.index(get_command) < commands.index(b'SLOWLOG RESET') + assert commands.index(get_command) < commands.index(b"SLOWLOG RESET") # make sure other attributes are typed correctly - assert isinstance(slowlog[0]['start_time'], int) - assert isinstance(slowlog[0]['duration'], int) + assert isinstance(slowlog[0]["start_time"], int) + assert isinstance(slowlog[0]["duration"], int) # rollback the slowlog limit to its original value self._teardown_slowlog_test(r, node, slowlog_limit) def test_slowlog_get_limit(self, r, slowlog): assert r.slowlog_reset() - node = r.get_node_from_key('foo') + node = r.get_node_from_key("foo") slowlog_limit = self._init_slowlog_test(r, node) - r.get('foo') + r.get("foo") slowlog = r.slowlog_get(1, target_nodes=node) assert isinstance(slowlog, list) # only one command, based on the number we passed to slowlog_get() @@ -1059,8 +1087,8 @@ def test_slowlog_get_limit(self, r, slowlog): self._teardown_slowlog_test(r, node, slowlog_limit) def test_slowlog_length(self, r, slowlog): - r.get('foo') - node = r.nodes_manager.get_node_from_slot(key_slot(b'foo')) + r.get("foo") + node = r.nodes_manager.get_node_from_slot(key_slot(b"foo")) slowlog_len = r.slowlog_len(target_nodes=node) assert isinstance(slowlog_len, int) @@ -1070,47 +1098,46 @@ def test_time(self, r): assert isinstance(t[0], int) assert isinstance(t[1], int) - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_usage(self, r): - r.set('foo', 'bar') - assert isinstance(r.memory_usage('foo'), int) + r.set("foo", "bar") + assert isinstance(r.memory_usage("foo"), int) - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_malloc_stats(self, r): assert r.memory_malloc_stats() - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_stats(self, r): # put a key into the current db to make sure that "db." # has data - r.set('foo', 'bar') - node = r.nodes_manager.get_node_from_slot(key_slot(b'foo')) + r.set("foo", "bar") + node = r.nodes_manager.get_node_from_slot(key_slot(b"foo")) stats = r.memory_stats(target_nodes=node) assert isinstance(stats, dict) for key, value in stats.items(): - if key.startswith('db.'): + if key.startswith("db."): assert isinstance(value, dict) - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_help(self, r): with pytest.raises(NotImplementedError): r.memory_help() - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_doctor(self, r): with pytest.raises(NotImplementedError): r.memory_doctor() def test_lastsave(self, r): node = r.get_primaries()[0] - assert isinstance(r.lastsave(target_nodes=node), - datetime.datetime) + assert isinstance(r.lastsave(target_nodes=node), datetime.datetime) def test_cluster_echo(self, r): node = r.get_primaries()[0] - assert r.echo('foo bar', node) == b'foo bar' + assert r.echo("foo bar", node) == b"foo bar" - @skip_if_server_version_lt('1.0.0') + @skip_if_server_version_lt("1.0.0") def test_debug_segfault(self, r): with pytest.raises(NotImplementedError): r.debug_segfault() @@ -1118,39 +1145,41 @@ def test_debug_segfault(self, r): def test_config_resetstat(self, r): node = r.get_primaries()[0] r.ping(target_nodes=node) - prior_commands_processed = \ - int(r.info(target_nodes=node)['total_commands_processed']) + prior_commands_processed = int( + r.info(target_nodes=node)["total_commands_processed"] + ) assert prior_commands_processed >= 1 r.config_resetstat(target_nodes=node) - reset_commands_processed = \ - int(r.info(target_nodes=node)['total_commands_processed']) + reset_commands_processed = int( + r.info(target_nodes=node)["total_commands_processed"] + ) assert reset_commands_processed < prior_commands_processed - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_client_trackinginfo(self, r): node = r.get_primaries()[0] res = r.client_trackinginfo(target_nodes=node) assert len(res) > 2 - assert 'prefixes' in res + assert "prefixes" in res - @skip_if_server_version_lt('2.9.50') + @skip_if_server_version_lt("2.9.50") def test_client_pause(self, r): node = r.get_primaries()[0] assert r.client_pause(1, target_nodes=node) assert r.client_pause(timeout=1, target_nodes=node) with pytest.raises(RedisError): - r.client_pause(timeout='not an integer', target_nodes=node) + r.client_pause(timeout="not an integer", target_nodes=node) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_client_unpause(self, r): assert r.client_unpause() - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_client_id(self, r): node = r.get_primaries()[0] assert r.client_id(target_nodes=node) > 0 - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_client_unblock(self, r): node = r.get_primaries()[0] myid = r.client_id(target_nodes=node) @@ -1158,82 +1187,88 @@ def test_client_unblock(self, r): assert not r.client_unblock(myid, error=True, target_nodes=node) assert not r.client_unblock(myid, error=False, target_nodes=node) - @skip_if_server_version_lt('6.0.0') + @skip_if_server_version_lt("6.0.0") def test_client_getredir(self, r): node = r.get_primaries()[0] assert isinstance(r.client_getredir(target_nodes=node), int) assert r.client_getredir(target_nodes=node) == -1 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_client_info(self, r): node = r.get_primaries()[0] info = r.client_info(target_nodes=node) assert isinstance(info, dict) - assert 'addr' in info + assert "addr" in info - @skip_if_server_version_lt('2.6.9') + @skip_if_server_version_lt("2.6.9") def test_client_kill(self, r, r2): node = r.get_primaries()[0] - r.client_setname('redis-py-c1', target_nodes='all') - r2.client_setname('redis-py-c2', target_nodes='all') - clients = [client for client in r.client_list(target_nodes=node) - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + r.client_setname("redis-py-c1", target_nodes="all") + r2.client_setname("redis-py-c2", target_nodes="all") + clients = [ + client + for client in r.client_list(target_nodes=node) + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 2 - clients_by_name = {client.get('name'): client for client in clients} + clients_by_name = {client.get("name"): client for client in clients} - client_addr = clients_by_name['redis-py-c2'].get('addr') + client_addr = clients_by_name["redis-py-c2"].get("addr") assert r.client_kill(client_addr, target_nodes=node) is True - clients = [client for client in r.client_list(target_nodes=node) - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + clients = [ + client + for client in r.client_list(target_nodes=node) + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 1 - assert clients[0].get('name') == 'redis-py-c1' + assert clients[0].get("name") == "redis-py-c1" - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_cluster_bitop_not_empty_string(self, r): - r['{foo}a'] = '' - r.bitop('not', '{foo}r', '{foo}a') - assert r.get('{foo}r') is None + r["{foo}a"] = "" + r.bitop("not", "{foo}r", "{foo}a") + assert r.get("{foo}r") is None - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_cluster_bitop_not(self, r): - test_str = b'\xAA\x00\xFF\x55' + test_str = b"\xAA\x00\xFF\x55" correct = ~0xAA00FF55 & 0xFFFFFFFF - r['{foo}a'] = test_str - r.bitop('not', '{foo}r', '{foo}a') - assert int(binascii.hexlify(r['{foo}r']), 16) == correct + r["{foo}a"] = test_str + r.bitop("not", "{foo}r", "{foo}a") + assert int(binascii.hexlify(r["{foo}r"]), 16) == correct - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_cluster_bitop_not_in_place(self, r): - test_str = b'\xAA\x00\xFF\x55' + test_str = b"\xAA\x00\xFF\x55" correct = ~0xAA00FF55 & 0xFFFFFFFF - r['{foo}a'] = test_str - r.bitop('not', '{foo}a', '{foo}a') - assert int(binascii.hexlify(r['{foo}a']), 16) == correct + r["{foo}a"] = test_str + r.bitop("not", "{foo}a", "{foo}a") + assert int(binascii.hexlify(r["{foo}a"]), 16) == correct - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_cluster_bitop_single_string(self, r): - test_str = b'\x01\x02\xFF' - r['{foo}a'] = test_str - r.bitop('and', '{foo}res1', '{foo}a') - r.bitop('or', '{foo}res2', '{foo}a') - r.bitop('xor', '{foo}res3', '{foo}a') - assert r['{foo}res1'] == test_str - assert r['{foo}res2'] == test_str - assert r['{foo}res3'] == test_str - - @skip_if_server_version_lt('2.6.0') + test_str = b"\x01\x02\xFF" + r["{foo}a"] = test_str + r.bitop("and", "{foo}res1", "{foo}a") + r.bitop("or", "{foo}res2", "{foo}a") + r.bitop("xor", "{foo}res3", "{foo}a") + assert r["{foo}res1"] == test_str + assert r["{foo}res2"] == test_str + assert r["{foo}res3"] == test_str + + @skip_if_server_version_lt("2.6.0") def test_cluster_bitop_string_operands(self, r): - r['{foo}a'] = b'\x01\x02\xFF\xFF' - r['{foo}b'] = b'\x01\x02\xFF' - r.bitop('and', '{foo}res1', '{foo}a', '{foo}b') - r.bitop('or', '{foo}res2', '{foo}a', '{foo}b') - r.bitop('xor', '{foo}res3', '{foo}a', '{foo}b') - assert int(binascii.hexlify(r['{foo}res1']), 16) == 0x0102FF00 - assert int(binascii.hexlify(r['{foo}res2']), 16) == 0x0102FFFF - assert int(binascii.hexlify(r['{foo}res3']), 16) == 0x000000FF - - @skip_if_server_version_lt('6.2.0') + r["{foo}a"] = b"\x01\x02\xFF\xFF" + r["{foo}b"] = b"\x01\x02\xFF" + r.bitop("and", "{foo}res1", "{foo}a", "{foo}b") + r.bitop("or", "{foo}res2", "{foo}a", "{foo}b") + r.bitop("xor", "{foo}res3", "{foo}a", "{foo}b") + assert int(binascii.hexlify(r["{foo}res1"]), 16) == 0x0102FF00 + assert int(binascii.hexlify(r["{foo}res2"]), 16) == 0x0102FFFF + assert int(binascii.hexlify(r["{foo}res3"]), 16) == 0x000000FF + + @skip_if_server_version_lt("6.2.0") def test_cluster_copy(self, r): assert r.copy("{foo}a", "{foo}b") == 0 r.set("{foo}a", "bar") @@ -1241,449 +1276,493 @@ def test_cluster_copy(self, r): assert r.get("{foo}a") == b"bar" assert r.get("{foo}b") == b"bar" - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_cluster_copy_and_replace(self, r): r.set("{foo}a", "foo1") r.set("{foo}b", "foo2") assert r.copy("{foo}a", "{foo}b") == 0 assert r.copy("{foo}a", "{foo}b", replace=True) == 1 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_cluster_lmove(self, r): - r.rpush('{foo}a', 'one', 'two', 'three', 'four') - assert r.lmove('{foo}a', '{foo}b') - assert r.lmove('{foo}a', '{foo}b', 'right', 'left') + r.rpush("{foo}a", "one", "two", "three", "four") + assert r.lmove("{foo}a", "{foo}b") + assert r.lmove("{foo}a", "{foo}b", "right", "left") - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_cluster_blmove(self, r): - r.rpush('{foo}a', 'one', 'two', 'three', 'four') - assert r.blmove('{foo}a', '{foo}b', 5) - assert r.blmove('{foo}a', '{foo}b', 1, 'RIGHT', 'LEFT') + r.rpush("{foo}a", "one", "two", "three", "four") + assert r.blmove("{foo}a", "{foo}b", 5) + assert r.blmove("{foo}a", "{foo}b", 1, "RIGHT", "LEFT") def test_cluster_msetnx(self, r): - d = {'{foo}a': b'1', '{foo}b': b'2', '{foo}c': b'3'} + d = {"{foo}a": b"1", "{foo}b": b"2", "{foo}c": b"3"} assert r.msetnx(d) - d2 = {'{foo}a': b'x', '{foo}d': b'4'} + d2 = {"{foo}a": b"x", "{foo}d": b"4"} assert not r.msetnx(d2) for k, v in d.items(): assert r[k] == v - assert r.get('{foo}d') is None + assert r.get("{foo}d") is None def test_cluster_rename(self, r): - r['{foo}a'] = '1' - assert r.rename('{foo}a', '{foo}b') - assert r.get('{foo}a') is None - assert r['{foo}b'] == b'1' + r["{foo}a"] = "1" + assert r.rename("{foo}a", "{foo}b") + assert r.get("{foo}a") is None + assert r["{foo}b"] == b"1" def test_cluster_renamenx(self, r): - r['{foo}a'] = '1' - r['{foo}b'] = '2' - assert not r.renamenx('{foo}a', '{foo}b') - assert r['{foo}a'] == b'1' - assert r['{foo}b'] == b'2' + r["{foo}a"] = "1" + r["{foo}b"] = "2" + assert not r.renamenx("{foo}a", "{foo}b") + assert r["{foo}a"] == b"1" + assert r["{foo}b"] == b"2" # LIST COMMANDS def test_cluster_blpop(self, r): - r.rpush('{foo}a', '1', '2') - r.rpush('{foo}b', '3', '4') - assert r.blpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}b', b'3') - assert r.blpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}b', b'4') - assert r.blpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}a', b'1') - assert r.blpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}a', b'2') - assert r.blpop(['{foo}b', '{foo}a'], timeout=1) is None - r.rpush('{foo}c', '1') - assert r.blpop('{foo}c', timeout=1) == (b'{foo}c', b'1') + r.rpush("{foo}a", "1", "2") + r.rpush("{foo}b", "3", "4") + assert r.blpop(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}b", b"3") + assert r.blpop(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}b", b"4") + assert r.blpop(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}a", b"1") + assert r.blpop(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}a", b"2") + assert r.blpop(["{foo}b", "{foo}a"], timeout=1) is None + r.rpush("{foo}c", "1") + assert r.blpop("{foo}c", timeout=1) == (b"{foo}c", b"1") def test_cluster_brpop(self, r): - r.rpush('{foo}a', '1', '2') - r.rpush('{foo}b', '3', '4') - assert r.brpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}b', b'4') - assert r.brpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}b', b'3') - assert r.brpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}a', b'2') - assert r.brpop(['{foo}b', '{foo}a'], timeout=1) == (b'{foo}a', b'1') - assert r.brpop(['{foo}b', '{foo}a'], timeout=1) is None - r.rpush('{foo}c', '1') - assert r.brpop('{foo}c', timeout=1) == (b'{foo}c', b'1') + r.rpush("{foo}a", "1", "2") + r.rpush("{foo}b", "3", "4") + assert r.brpop(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}b", b"4") + assert r.brpop(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}b", b"3") + assert r.brpop(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}a", b"2") + assert r.brpop(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}a", b"1") + assert r.brpop(["{foo}b", "{foo}a"], timeout=1) is None + r.rpush("{foo}c", "1") + assert r.brpop("{foo}c", timeout=1) == (b"{foo}c", b"1") def test_cluster_brpoplpush(self, r): - r.rpush('{foo}a', '1', '2') - r.rpush('{foo}b', '3', '4') - assert r.brpoplpush('{foo}a', '{foo}b') == b'2' - assert r.brpoplpush('{foo}a', '{foo}b') == b'1' - assert r.brpoplpush('{foo}a', '{foo}b', timeout=1) is None - assert r.lrange('{foo}a', 0, -1) == [] - assert r.lrange('{foo}b', 0, -1) == [b'1', b'2', b'3', b'4'] + r.rpush("{foo}a", "1", "2") + r.rpush("{foo}b", "3", "4") + assert r.brpoplpush("{foo}a", "{foo}b") == b"2" + assert r.brpoplpush("{foo}a", "{foo}b") == b"1" + assert r.brpoplpush("{foo}a", "{foo}b", timeout=1) is None + assert r.lrange("{foo}a", 0, -1) == [] + assert r.lrange("{foo}b", 0, -1) == [b"1", b"2", b"3", b"4"] def test_cluster_brpoplpush_empty_string(self, r): - r.rpush('{foo}a', '') - assert r.brpoplpush('{foo}a', '{foo}b') == b'' + r.rpush("{foo}a", "") + assert r.brpoplpush("{foo}a", "{foo}b") == b"" def test_cluster_rpoplpush(self, r): - r.rpush('{foo}a', 'a1', 'a2', 'a3') - r.rpush('{foo}b', 'b1', 'b2', 'b3') - assert r.rpoplpush('{foo}a', '{foo}b') == b'a3' - assert r.lrange('{foo}a', 0, -1) == [b'a1', b'a2'] - assert r.lrange('{foo}b', 0, -1) == [b'a3', b'b1', b'b2', b'b3'] + r.rpush("{foo}a", "a1", "a2", "a3") + r.rpush("{foo}b", "b1", "b2", "b3") + assert r.rpoplpush("{foo}a", "{foo}b") == b"a3" + assert r.lrange("{foo}a", 0, -1) == [b"a1", b"a2"] + assert r.lrange("{foo}b", 0, -1) == [b"a3", b"b1", b"b2", b"b3"] def test_cluster_sdiff(self, r): - r.sadd('{foo}a', '1', '2', '3') - assert r.sdiff('{foo}a', '{foo}b') == {b'1', b'2', b'3'} - r.sadd('{foo}b', '2', '3') - assert r.sdiff('{foo}a', '{foo}b') == {b'1'} + r.sadd("{foo}a", "1", "2", "3") + assert r.sdiff("{foo}a", "{foo}b") == {b"1", b"2", b"3"} + r.sadd("{foo}b", "2", "3") + assert r.sdiff("{foo}a", "{foo}b") == {b"1"} def test_cluster_sdiffstore(self, r): - r.sadd('{foo}a', '1', '2', '3') - assert r.sdiffstore('{foo}c', '{foo}a', '{foo}b') == 3 - assert r.smembers('{foo}c') == {b'1', b'2', b'3'} - r.sadd('{foo}b', '2', '3') - assert r.sdiffstore('{foo}c', '{foo}a', '{foo}b') == 1 - assert r.smembers('{foo}c') == {b'1'} + r.sadd("{foo}a", "1", "2", "3") + assert r.sdiffstore("{foo}c", "{foo}a", "{foo}b") == 3 + assert r.smembers("{foo}c") == {b"1", b"2", b"3"} + r.sadd("{foo}b", "2", "3") + assert r.sdiffstore("{foo}c", "{foo}a", "{foo}b") == 1 + assert r.smembers("{foo}c") == {b"1"} def test_cluster_sinter(self, r): - r.sadd('{foo}a', '1', '2', '3') - assert r.sinter('{foo}a', '{foo}b') == set() - r.sadd('{foo}b', '2', '3') - assert r.sinter('{foo}a', '{foo}b') == {b'2', b'3'} + r.sadd("{foo}a", "1", "2", "3") + assert r.sinter("{foo}a", "{foo}b") == set() + r.sadd("{foo}b", "2", "3") + assert r.sinter("{foo}a", "{foo}b") == {b"2", b"3"} def test_cluster_sinterstore(self, r): - r.sadd('{foo}a', '1', '2', '3') - assert r.sinterstore('{foo}c', '{foo}a', '{foo}b') == 0 - assert r.smembers('{foo}c') == set() - r.sadd('{foo}b', '2', '3') - assert r.sinterstore('{foo}c', '{foo}a', '{foo}b') == 2 - assert r.smembers('{foo}c') == {b'2', b'3'} + r.sadd("{foo}a", "1", "2", "3") + assert r.sinterstore("{foo}c", "{foo}a", "{foo}b") == 0 + assert r.smembers("{foo}c") == set() + r.sadd("{foo}b", "2", "3") + assert r.sinterstore("{foo}c", "{foo}a", "{foo}b") == 2 + assert r.smembers("{foo}c") == {b"2", b"3"} def test_cluster_smove(self, r): - r.sadd('{foo}a', 'a1', 'a2') - r.sadd('{foo}b', 'b1', 'b2') - assert r.smove('{foo}a', '{foo}b', 'a1') - assert r.smembers('{foo}a') == {b'a2'} - assert r.smembers('{foo}b') == {b'b1', b'b2', b'a1'} + r.sadd("{foo}a", "a1", "a2") + r.sadd("{foo}b", "b1", "b2") + assert r.smove("{foo}a", "{foo}b", "a1") + assert r.smembers("{foo}a") == {b"a2"} + assert r.smembers("{foo}b") == {b"b1", b"b2", b"a1"} def test_cluster_sunion(self, r): - r.sadd('{foo}a', '1', '2') - r.sadd('{foo}b', '2', '3') - assert r.sunion('{foo}a', '{foo}b') == {b'1', b'2', b'3'} + r.sadd("{foo}a", "1", "2") + r.sadd("{foo}b", "2", "3") + assert r.sunion("{foo}a", "{foo}b") == {b"1", b"2", b"3"} def test_cluster_sunionstore(self, r): - r.sadd('{foo}a', '1', '2') - r.sadd('{foo}b', '2', '3') - assert r.sunionstore('{foo}c', '{foo}a', '{foo}b') == 3 - assert r.smembers('{foo}c') == {b'1', b'2', b'3'} + r.sadd("{foo}a", "1", "2") + r.sadd("{foo}b", "2", "3") + assert r.sunionstore("{foo}c", "{foo}a", "{foo}b") == 3 + assert r.smembers("{foo}c") == {b"1", b"2", b"3"} - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_cluster_zdiff(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) - r.zadd('{foo}b', {'a1': 1, 'a2': 2}) - assert r.zdiff(['{foo}a', '{foo}b']) == [b'a3'] - assert r.zdiff(['{foo}a', '{foo}b'], withscores=True) == [b'a3', b'3'] + r.zadd("{foo}a", {"a1": 1, "a2": 2, "a3": 3}) + r.zadd("{foo}b", {"a1": 1, "a2": 2}) + assert r.zdiff(["{foo}a", "{foo}b"]) == [b"a3"] + assert r.zdiff(["{foo}a", "{foo}b"], withscores=True) == [b"a3", b"3"] - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_cluster_zdiffstore(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) - r.zadd('{foo}b', {'a1': 1, 'a2': 2}) - assert r.zdiffstore("{foo}out", ['{foo}a', '{foo}b']) - assert r.zrange("{foo}out", 0, -1) == [b'a3'] - assert r.zrange("{foo}out", 0, -1, withscores=True) == [(b'a3', 3.0)] + r.zadd("{foo}a", {"a1": 1, "a2": 2, "a3": 3}) + r.zadd("{foo}b", {"a1": 1, "a2": 2}) + assert r.zdiffstore("{foo}out", ["{foo}a", "{foo}b"]) + assert r.zrange("{foo}out", 0, -1) == [b"a3"] + assert r.zrange("{foo}out", 0, -1, withscores=True) == [(b"a3", 3.0)] - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_cluster_zinter(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 1}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinter(['{foo}a', '{foo}b', '{foo}c']) == [b'a3', b'a1'] + r.zadd("{foo}a", {"a1": 1, "a2": 2, "a3": 1}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zinter(["{foo}a", "{foo}b", "{foo}c"]) == [b"a3", b"a1"] # invalid aggregation with pytest.raises(DataError): - r.zinter(['{foo}a', '{foo}b', '{foo}c'], - aggregate='foo', withscores=True) + r.zinter(["{foo}a", "{foo}b", "{foo}c"], aggregate="foo", withscores=True) # aggregate with SUM - assert r.zinter(['{foo}a', '{foo}b', '{foo}c'], withscores=True) \ - == [(b'a3', 8), (b'a1', 9)] + assert r.zinter(["{foo}a", "{foo}b", "{foo}c"], withscores=True) == [ + (b"a3", 8), + (b"a1", 9), + ] # aggregate with MAX - assert r.zinter(['{foo}a', '{foo}b', '{foo}c'], aggregate='MAX', - withscores=True) \ - == [(b'a3', 5), (b'a1', 6)] + assert r.zinter( + ["{foo}a", "{foo}b", "{foo}c"], aggregate="MAX", withscores=True + ) == [(b"a3", 5), (b"a1", 6)] # aggregate with MIN - assert r.zinter(['{foo}a', '{foo}b', '{foo}c'], aggregate='MIN', - withscores=True) \ - == [(b'a1', 1), (b'a3', 1)] + assert r.zinter( + ["{foo}a", "{foo}b", "{foo}c"], aggregate="MIN", withscores=True + ) == [(b"a1", 1), (b"a3", 1)] # with weights - assert r.zinter({'{foo}a': 1, '{foo}b': 2, '{foo}c': 3}, - withscores=True) \ - == [(b'a3', 20), (b'a1', 23)] + assert r.zinter({"{foo}a": 1, "{foo}b": 2, "{foo}c": 3}, withscores=True) == [ + (b"a3", 20), + (b"a1", 23), + ] def test_cluster_zinterstore_sum(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinterstore('{foo}d', ['{foo}a', '{foo}b', '{foo}c']) == 2 - assert r.zrange('{foo}d', 0, -1, withscores=True) == \ - [(b'a3', 8), (b'a1', 9)] + r.zadd("{foo}a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zinterstore("{foo}d", ["{foo}a", "{foo}b", "{foo}c"]) == 2 + assert r.zrange("{foo}d", 0, -1, withscores=True) == [(b"a3", 8), (b"a1", 9)] def test_cluster_zinterstore_max(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinterstore( - '{foo}d', ['{foo}a', '{foo}b', '{foo}c'], aggregate='MAX') == 2 - assert r.zrange('{foo}d', 0, -1, withscores=True) == \ - [(b'a3', 5), (b'a1', 6)] + r.zadd("{foo}a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert ( + r.zinterstore("{foo}d", ["{foo}a", "{foo}b", "{foo}c"], aggregate="MAX") + == 2 + ) + assert r.zrange("{foo}d", 0, -1, withscores=True) == [(b"a3", 5), (b"a1", 6)] def test_cluster_zinterstore_min(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) - r.zadd('{foo}b', {'a1': 2, 'a2': 3, 'a3': 5}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinterstore( - '{foo}d', ['{foo}a', '{foo}b', '{foo}c'], aggregate='MIN') == 2 - assert r.zrange('{foo}d', 0, -1, withscores=True) == \ - [(b'a1', 1), (b'a3', 3)] + r.zadd("{foo}a", {"a1": 1, "a2": 2, "a3": 3}) + r.zadd("{foo}b", {"a1": 2, "a2": 3, "a3": 5}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert ( + r.zinterstore("{foo}d", ["{foo}a", "{foo}b", "{foo}c"], aggregate="MIN") + == 2 + ) + assert r.zrange("{foo}d", 0, -1, withscores=True) == [(b"a1", 1), (b"a3", 3)] def test_cluster_zinterstore_with_weight(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinterstore( - '{foo}d', {'{foo}a': 1, '{foo}b': 2, '{foo}c': 3}) == 2 - assert r.zrange('{foo}d', 0, -1, withscores=True) == \ - [(b'a3', 20), (b'a1', 23)] - - @skip_if_server_version_lt('4.9.0') + r.zadd("{foo}a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zinterstore("{foo}d", {"{foo}a": 1, "{foo}b": 2, "{foo}c": 3}) == 2 + assert r.zrange("{foo}d", 0, -1, withscores=True) == [(b"a3", 20), (b"a1", 23)] + + @skip_if_server_version_lt("4.9.0") def test_cluster_bzpopmax(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 2}) - r.zadd('{foo}b', {'b1': 10, 'b2': 20}) - assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) == ( - b'{foo}b', b'b2', 20) - assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) == ( - b'{foo}b', b'b1', 10) - assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) == ( - b'{foo}a', b'a2', 2) - assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) == ( - b'{foo}a', b'a1', 1) - assert r.bzpopmax(['{foo}b', '{foo}a'], timeout=1) is None - r.zadd('{foo}c', {'c1': 100}) - assert r.bzpopmax('{foo}c', timeout=1) == (b'{foo}c', b'c1', 100) - - @skip_if_server_version_lt('4.9.0') + r.zadd("{foo}a", {"a1": 1, "a2": 2}) + r.zadd("{foo}b", {"b1": 10, "b2": 20}) + assert r.bzpopmax(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}b", b"b2", 20) + assert r.bzpopmax(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}b", b"b1", 10) + assert r.bzpopmax(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}a", b"a2", 2) + assert r.bzpopmax(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}a", b"a1", 1) + assert r.bzpopmax(["{foo}b", "{foo}a"], timeout=1) is None + r.zadd("{foo}c", {"c1": 100}) + assert r.bzpopmax("{foo}c", timeout=1) == (b"{foo}c", b"c1", 100) + + @skip_if_server_version_lt("4.9.0") def test_cluster_bzpopmin(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 2}) - r.zadd('{foo}b', {'b1': 10, 'b2': 20}) - assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) == ( - b'{foo}b', b'b1', 10) - assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) == ( - b'{foo}b', b'b2', 20) - assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) == ( - b'{foo}a', b'a1', 1) - assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) == ( - b'{foo}a', b'a2', 2) - assert r.bzpopmin(['{foo}b', '{foo}a'], timeout=1) is None - r.zadd('{foo}c', {'c1': 100}) - assert r.bzpopmin('{foo}c', timeout=1) == (b'{foo}c', b'c1', 100) - - @skip_if_server_version_lt('6.2.0') + r.zadd("{foo}a", {"a1": 1, "a2": 2}) + r.zadd("{foo}b", {"b1": 10, "b2": 20}) + assert r.bzpopmin(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}b", b"b1", 10) + assert r.bzpopmin(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}b", b"b2", 20) + assert r.bzpopmin(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}a", b"a1", 1) + assert r.bzpopmin(["{foo}b", "{foo}a"], timeout=1) == (b"{foo}a", b"a2", 2) + assert r.bzpopmin(["{foo}b", "{foo}a"], timeout=1) is None + r.zadd("{foo}c", {"c1": 100}) + assert r.bzpopmin("{foo}c", timeout=1) == (b"{foo}c", b"c1", 100) + + @skip_if_server_version_lt("6.2.0") def test_cluster_zrangestore(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zrangestore('{foo}b', '{foo}a', 0, 1) - assert r.zrange('{foo}b', 0, -1) == [b'a1', b'a2'] - assert r.zrangestore('{foo}b', '{foo}a', 1, 2) - assert r.zrange('{foo}b', 0, -1) == [b'a2', b'a3'] - assert r.zrange('{foo}b', 0, -1, withscores=True) == \ - [(b'a2', 2), (b'a3', 3)] + r.zadd("{foo}a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zrangestore("{foo}b", "{foo}a", 0, 1) + assert r.zrange("{foo}b", 0, -1) == [b"a1", b"a2"] + assert r.zrangestore("{foo}b", "{foo}a", 1, 2) + assert r.zrange("{foo}b", 0, -1) == [b"a2", b"a3"] + assert r.zrange("{foo}b", 0, -1, withscores=True) == [(b"a2", 2), (b"a3", 3)] # reversed order - assert r.zrangestore('{foo}b', '{foo}a', 1, 2, desc=True) - assert r.zrange('{foo}b', 0, -1) == [b'a1', b'a2'] + assert r.zrangestore("{foo}b", "{foo}a", 1, 2, desc=True) + assert r.zrange("{foo}b", 0, -1) == [b"a1", b"a2"] # by score - assert r.zrangestore('{foo}b', '{foo}a', 2, 1, byscore=True, - offset=0, num=1, desc=True) - assert r.zrange('{foo}b', 0, -1) == [b'a2'] + assert r.zrangestore( + "{foo}b", "{foo}a", 2, 1, byscore=True, offset=0, num=1, desc=True + ) + assert r.zrange("{foo}b", 0, -1) == [b"a2"] # by lex - assert r.zrangestore('{foo}b', '{foo}a', '[a2', '(a3', bylex=True, - offset=0, num=1) - assert r.zrange('{foo}b', 0, -1) == [b'a2'] + assert r.zrangestore( + "{foo}b", "{foo}a", "[a2", "(a3", bylex=True, offset=0, num=1 + ) + assert r.zrange("{foo}b", 0, -1) == [b"a2"] - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_cluster_zunion(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) + r.zadd("{foo}a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) # sum - assert r.zunion(['{foo}a', '{foo}b', '{foo}c']) == \ - [b'a2', b'a4', b'a3', b'a1'] - assert r.zunion(['{foo}a', '{foo}b', '{foo}c'], withscores=True) == \ - [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + assert r.zunion(["{foo}a", "{foo}b", "{foo}c"]) == [b"a2", b"a4", b"a3", b"a1"] + assert r.zunion(["{foo}a", "{foo}b", "{foo}c"], withscores=True) == [ + (b"a2", 3), + (b"a4", 4), + (b"a3", 8), + (b"a1", 9), + ] # max - assert r.zunion(['{foo}a', '{foo}b', '{foo}c'], aggregate='MAX', - withscores=True) \ - == [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + assert r.zunion( + ["{foo}a", "{foo}b", "{foo}c"], aggregate="MAX", withscores=True + ) == [(b"a2", 2), (b"a4", 4), (b"a3", 5), (b"a1", 6)] # min - assert r.zunion(['{foo}a', '{foo}b', '{foo}c'], aggregate='MIN', - withscores=True) \ - == [(b'a1', 1), (b'a2', 1), (b'a3', 1), (b'a4', 4)] + assert r.zunion( + ["{foo}a", "{foo}b", "{foo}c"], aggregate="MIN", withscores=True + ) == [(b"a1", 1), (b"a2", 1), (b"a3", 1), (b"a4", 4)] # with weight - assert r.zunion({'{foo}a': 1, '{foo}b': 2, '{foo}c': 3}, - withscores=True) \ - == [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] + assert r.zunion({"{foo}a": 1, "{foo}b": 2, "{foo}c": 3}, withscores=True) == [ + (b"a2", 5), + (b"a4", 12), + (b"a3", 20), + (b"a1", 23), + ] def test_cluster_zunionstore_sum(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zunionstore('{foo}d', ['{foo}a', '{foo}b', '{foo}c']) == 4 - assert r.zrange('{foo}d', 0, -1, withscores=True) == \ - [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + r.zadd("{foo}a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zunionstore("{foo}d", ["{foo}a", "{foo}b", "{foo}c"]) == 4 + assert r.zrange("{foo}d", 0, -1, withscores=True) == [ + (b"a2", 3), + (b"a4", 4), + (b"a3", 8), + (b"a1", 9), + ] def test_cluster_zunionstore_max(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zunionstore( - '{foo}d', ['{foo}a', '{foo}b', '{foo}c'], aggregate='MAX') == 4 - assert r.zrange('{foo}d', 0, -1, withscores=True) == \ - [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + r.zadd("{foo}a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert ( + r.zunionstore("{foo}d", ["{foo}a", "{foo}b", "{foo}c"], aggregate="MAX") + == 4 + ) + assert r.zrange("{foo}d", 0, -1, withscores=True) == [ + (b"a2", 2), + (b"a4", 4), + (b"a3", 5), + (b"a1", 6), + ] def test_cluster_zunionstore_min(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 2, 'a3': 3}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 4}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zunionstore( - '{foo}d', ['{foo}a', '{foo}b', '{foo}c'], aggregate='MIN') == 4 - assert r.zrange('{foo}d', 0, -1, withscores=True) == \ - [(b'a1', 1), (b'a2', 2), (b'a3', 3), (b'a4', 4)] + r.zadd("{foo}a", {"a1": 1, "a2": 2, "a3": 3}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 4}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert ( + r.zunionstore("{foo}d", ["{foo}a", "{foo}b", "{foo}c"], aggregate="MIN") + == 4 + ) + assert r.zrange("{foo}d", 0, -1, withscores=True) == [ + (b"a1", 1), + (b"a2", 2), + (b"a3", 3), + (b"a4", 4), + ] def test_cluster_zunionstore_with_weight(self, r): - r.zadd('{foo}a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('{foo}b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('{foo}c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zunionstore( - '{foo}d', {'{foo}a': 1, '{foo}b': 2, '{foo}c': 3}) == 4 - assert r.zrange('{foo}d', 0, -1, withscores=True) == \ - [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] - - @skip_if_server_version_lt('2.8.9') + r.zadd("{foo}a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("{foo}b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("{foo}c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zunionstore("{foo}d", {"{foo}a": 1, "{foo}b": 2, "{foo}c": 3}) == 4 + assert r.zrange("{foo}d", 0, -1, withscores=True) == [ + (b"a2", 5), + (b"a4", 12), + (b"a3", 20), + (b"a1", 23), + ] + + @skip_if_server_version_lt("2.8.9") def test_cluster_pfcount(self, r): - members = {b'1', b'2', b'3'} - r.pfadd('{foo}a', *members) - assert r.pfcount('{foo}a') == len(members) - members_b = {b'2', b'3', b'4'} - r.pfadd('{foo}b', *members_b) - assert r.pfcount('{foo}b') == len(members_b) - assert r.pfcount('{foo}a', '{foo}b') == len(members_b.union(members)) - - @skip_if_server_version_lt('2.8.9') + members = {b"1", b"2", b"3"} + r.pfadd("{foo}a", *members) + assert r.pfcount("{foo}a") == len(members) + members_b = {b"2", b"3", b"4"} + r.pfadd("{foo}b", *members_b) + assert r.pfcount("{foo}b") == len(members_b) + assert r.pfcount("{foo}a", "{foo}b") == len(members_b.union(members)) + + @skip_if_server_version_lt("2.8.9") def test_cluster_pfmerge(self, r): - mema = {b'1', b'2', b'3'} - memb = {b'2', b'3', b'4'} - memc = {b'5', b'6', b'7'} - r.pfadd('{foo}a', *mema) - r.pfadd('{foo}b', *memb) - r.pfadd('{foo}c', *memc) - r.pfmerge('{foo}d', '{foo}c', '{foo}a') - assert r.pfcount('{foo}d') == 6 - r.pfmerge('{foo}d', '{foo}b') - assert r.pfcount('{foo}d') == 7 + mema = {b"1", b"2", b"3"} + memb = {b"2", b"3", b"4"} + memc = {b"5", b"6", b"7"} + r.pfadd("{foo}a", *mema) + r.pfadd("{foo}b", *memb) + r.pfadd("{foo}c", *memc) + r.pfmerge("{foo}d", "{foo}c", "{foo}a") + assert r.pfcount("{foo}d") == 6 + r.pfmerge("{foo}d", "{foo}b") + assert r.pfcount("{foo}d") == 7 def test_cluster_sort_store(self, r): - r.rpush('{foo}a', '2', '3', '1') - assert r.sort('{foo}a', store='{foo}sorted_values') == 3 - assert r.lrange('{foo}sorted_values', 0, -1) == [b'1', b'2', b'3'] + r.rpush("{foo}a", "2", "3", "1") + assert r.sort("{foo}a", store="{foo}sorted_values") == 3 + assert r.lrange("{foo}sorted_values", 0, -1) == [b"1", b"2", b"3"] # GEO COMMANDS - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_cluster_geosearchstore(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('{foo}barcelona', values) - r.geosearchstore('{foo}places_barcelona', '{foo}barcelona', - longitude=2.191, latitude=41.433, radius=1000) - assert r.zrange('{foo}places_barcelona', 0, -1) == [b'place1'] + r.geoadd("{foo}barcelona", values) + r.geosearchstore( + "{foo}places_barcelona", + "{foo}barcelona", + longitude=2.191, + latitude=41.433, + radius=1000, + ) + assert r.zrange("{foo}places_barcelona", 0, -1) == [b"place1"] @skip_unless_arch_bits(64) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_geosearchstore_dist(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('{foo}barcelona', values) - r.geosearchstore('{foo}places_barcelona', '{foo}barcelona', - longitude=2.191, latitude=41.433, - radius=1000, storedist=True) + r.geoadd("{foo}barcelona", values) + r.geosearchstore( + "{foo}places_barcelona", + "{foo}barcelona", + longitude=2.191, + latitude=41.433, + radius=1000, + storedist=True, + ) # instead of save the geo score, the distance is saved. - assert r.zscore('{foo}places_barcelona', 'place1') == 88.05060698409301 + assert r.zscore("{foo}places_barcelona", "place1") == 88.05060698409301 - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_cluster_georadius_store(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('{foo}barcelona', values) - r.georadius('{foo}barcelona', 2.191, 41.433, - 1000, store='{foo}places_barcelona') - assert r.zrange('{foo}places_barcelona', 0, -1) == [b'place1'] + r.geoadd("{foo}barcelona", values) + r.georadius( + "{foo}barcelona", 2.191, 41.433, 1000, store="{foo}places_barcelona" + ) + assert r.zrange("{foo}places_barcelona", 0, -1) == [b"place1"] @skip_unless_arch_bits(64) - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_cluster_georadius_store_dist(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('{foo}barcelona', values) - r.georadius('{foo}barcelona', 2.191, 41.433, 1000, - store_dist='{foo}places_barcelona') + r.geoadd("{foo}barcelona", values) + r.georadius( + "{foo}barcelona", 2.191, 41.433, 1000, store_dist="{foo}places_barcelona" + ) # instead of save the geo score, the distance is saved. - assert r.zscore('{foo}places_barcelona', 'place1') == 88.05060698409301 + assert r.zscore("{foo}places_barcelona", "place1") == 88.05060698409301 def test_cluster_dbsize(self, r): - d = {'a': b'1', 'b': b'2', 'c': b'3', 'd': b'4'} + d = {"a": b"1", "b": b"2", "c": b"3", "d": b"4"} assert r.mset_nonatomic(d) - assert r.dbsize(target_nodes='primaries') == len(d) + assert r.dbsize(target_nodes="primaries") == len(d) def test_cluster_keys(self, r): assert r.keys() == [] - keys_with_underscores = {b'test_a', b'test_b'} - keys = keys_with_underscores.union({b'testc'}) + keys_with_underscores = {b"test_a", b"test_b"} + keys = keys_with_underscores.union({b"testc"}) for key in keys: r[key] = 1 - assert set(r.keys(pattern='test_*', target_nodes='primaries')) == \ - keys_with_underscores - assert set(r.keys(pattern='test*', target_nodes='primaries')) == keys + assert ( + set(r.keys(pattern="test_*", target_nodes="primaries")) + == keys_with_underscores + ) + assert set(r.keys(pattern="test*", target_nodes="primaries")) == keys # SCAN COMMANDS - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_cluster_scan(self, r): - r.set('a', 1) - r.set('b', 2) - r.set('c', 3) - cursor, keys = r.scan(target_nodes='primaries') + r.set("a", 1) + r.set("b", 2) + r.set("c", 3) + cursor, keys = r.scan(target_nodes="primaries") assert cursor == 0 - assert set(keys) == {b'a', b'b', b'c'} - _, keys = r.scan(match='a', target_nodes='primaries') - assert set(keys) == {b'a'} + assert set(keys) == {b"a", b"b", b"c"} + _, keys = r.scan(match="a", target_nodes="primaries") + assert set(keys) == {b"a"} @skip_if_server_version_lt("6.0.0") def test_cluster_scan_type(self, r): - r.sadd('a-set', 1) - r.hset('a-hash', 'foo', 2) - r.lpush('a-list', 'aux', 3) - _, keys = r.scan(match='a*', _type='SET', target_nodes='primaries') - assert set(keys) == {b'a-set'} + r.sadd("a-set", 1) + r.hset("a-hash", "foo", 2) + r.lpush("a-list", "aux", 3) + _, keys = r.scan(match="a*", _type="SET", target_nodes="primaries") + assert set(keys) == {b"a-set"} - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_cluster_scan_iter(self, r): - r.set('a', 1) - r.set('b', 2) - r.set('c', 3) - keys = list(r.scan_iter(target_nodes='primaries')) - assert set(keys) == {b'a', b'b', b'c'} - keys = list(r.scan_iter(match='a', target_nodes='primaries')) - assert set(keys) == {b'a'} + r.set("a", 1) + r.set("b", 2) + r.set("c", 3) + keys = list(r.scan_iter(target_nodes="primaries")) + assert set(keys) == {b"a", b"b", b"c"} + keys = list(r.scan_iter(match="a", target_nodes="primaries")) + assert set(keys) == {b"a"} def test_cluster_randomkey(self, r): - node = r.get_node_from_key('{foo}') + node = r.get_node_from_key("{foo}") assert r.randomkey(target_nodes=node) is None - for key in ('{foo}a', '{foo}b', '{foo}c'): + for key in ("{foo}a", "{foo}b", "{foo}c"): r[key] = 1 - assert r.randomkey(target_nodes=node) in \ - (b'{foo}a', b'{foo}b', b'{foo}c') + assert r.randomkey(target_nodes=node) in (b"{foo}a", b"{foo}b", b"{foo}c") @pytest.mark.onlycluster @@ -1704,7 +1783,7 @@ def test_load_balancer(self, r): node_5 = ClusterNode(default_host, 6375, REPLICA) n_manager.slots_cache = { slot_1: [node_1, node_2, node_3], - slot_2: [node_4, node_5] + slot_2: [node_4, node_5], } primary1_name = n_manager.slots_cache[slot_1][0].name primary2_name = n_manager.slots_cache[slot_2][0].name @@ -1730,17 +1809,17 @@ def test_init_slots_cache_not_all_slots_covered(self): """ # Missing slot 5460 cluster_slots = [ - [0, 5459, ['127.0.0.1', 7000], ['127.0.0.1', 7003]], - [5461, 10922, ['127.0.0.1', 7001], - ['127.0.0.1', 7004]], - [10923, 16383, ['127.0.0.1', 7002], - ['127.0.0.1', 7005]], + [0, 5459, ["127.0.0.1", 7000], ["127.0.0.1", 7003]], + [5461, 10922, ["127.0.0.1", 7001], ["127.0.0.1", 7004]], + [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], ] with pytest.raises(RedisClusterException) as ex: - get_mocked_redis_client(host=default_host, port=default_port, - cluster_slots=cluster_slots) + get_mocked_redis_client( + host=default_host, port=default_port, cluster_slots=cluster_slots + ) assert str(ex.value).startswith( - "All slots are not covered after query all startup_nodes.") + "All slots are not covered after query all startup_nodes." + ) def test_init_slots_cache_not_require_full_coverage_error(self): """ @@ -1750,18 +1829,19 @@ def test_init_slots_cache_not_require_full_coverage_error(self): """ # Missing slot 5460 cluster_slots = [ - [0, 5459, ['127.0.0.1', 7000], ['127.0.0.1', 7003]], - [5461, 10922, ['127.0.0.1', 7001], - ['127.0.0.1', 7004]], - [10923, 16383, ['127.0.0.1', 7002], - ['127.0.0.1', 7005]], + [0, 5459, ["127.0.0.1", 7000], ["127.0.0.1", 7003]], + [5461, 10922, ["127.0.0.1", 7001], ["127.0.0.1", 7004]], + [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], ] with pytest.raises(RedisClusterException): - get_mocked_redis_client(host=default_host, port=default_port, - cluster_slots=cluster_slots, - require_full_coverage=False, - coverage_result='yes') + get_mocked_redis_client( + host=default_host, + port=default_port, + cluster_slots=cluster_slots, + require_full_coverage=False, + coverage_result="yes", + ) def test_init_slots_cache_not_require_full_coverage_success(self): """ @@ -1771,17 +1851,18 @@ def test_init_slots_cache_not_require_full_coverage_success(self): """ # Missing slot 5460 cluster_slots = [ - [0, 5459, ['127.0.0.1', 7000], ['127.0.0.1', 7003]], - [5461, 10922, ['127.0.0.1', 7001], - ['127.0.0.1', 7004]], - [10923, 16383, ['127.0.0.1', 7002], - ['127.0.0.1', 7005]], + [0, 5459, ["127.0.0.1", 7000], ["127.0.0.1", 7003]], + [5461, 10922, ["127.0.0.1", 7001], ["127.0.0.1", 7004]], + [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], ] - rc = get_mocked_redis_client(host=default_host, port=default_port, - cluster_slots=cluster_slots, - require_full_coverage=False, - coverage_result='no') + rc = get_mocked_redis_client( + host=default_host, + port=default_port, + cluster_slots=cluster_slots, + require_full_coverage=False, + coverage_result="no", + ) assert 5460 not in rc.nodes_manager.slots_cache @@ -1793,20 +1874,22 @@ def test_init_slots_cache_not_require_full_coverage_skips_check(self): """ # Missing slot 5460 cluster_slots = [ - [0, 5459, ['127.0.0.1', 7000], ['127.0.0.1', 7003]], - [5461, 10922, ['127.0.0.1', 7001], - ['127.0.0.1', 7004]], - [10923, 16383, ['127.0.0.1', 7002], - ['127.0.0.1', 7005]], + [0, 5459, ["127.0.0.1", 7000], ["127.0.0.1", 7003]], + [5461, 10922, ["127.0.0.1", 7001], ["127.0.0.1", 7004]], + [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], ] - with patch.object(NodesManager, - 'cluster_require_full_coverage') as conf_check_mock: - rc = get_mocked_redis_client(host=default_host, port=default_port, - cluster_slots=cluster_slots, - require_full_coverage=False, - skip_full_coverage_check=True, - coverage_result='no') + with patch.object( + NodesManager, "cluster_require_full_coverage" + ) as conf_check_mock: + rc = get_mocked_redis_client( + host=default_host, + port=default_port, + cluster_slots=cluster_slots, + require_full_coverage=False, + skip_full_coverage_check=True, + coverage_result="no", + ) assert conf_check_mock.called is False assert 5460 not in rc.nodes_manager.slots_cache @@ -1816,17 +1899,18 @@ def test_init_slots_cache(self): Test that slots cache can in initialized and all slots are covered """ good_slots_resp = [ - [0, 5460, ['127.0.0.1', 7000], ['127.0.0.2', 7003]], - [5461, 10922, ['127.0.0.1', 7001], ['127.0.0.2', 7004]], - [10923, 16383, ['127.0.0.1', 7002], ['127.0.0.2', 7005]], + [0, 5460, ["127.0.0.1", 7000], ["127.0.0.2", 7003]], + [5461, 10922, ["127.0.0.1", 7001], ["127.0.0.2", 7004]], + [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.2", 7005]], ] - rc = get_mocked_redis_client(host=default_host, port=default_port, - cluster_slots=good_slots_resp) + rc = get_mocked_redis_client( + host=default_host, port=default_port, cluster_slots=good_slots_resp + ) n_manager = rc.nodes_manager assert len(n_manager.slots_cache) == REDIS_CLUSTER_HASH_SLOTS for slot_info in good_slots_resp: - all_hosts = ['127.0.0.1', '127.0.0.2'] + all_hosts = ["127.0.0.1", "127.0.0.2"] all_ports = [7000, 7001, 7002, 7003, 7004, 7005] slot_start = slot_info[0] slot_end = slot_info[1] @@ -1861,8 +1945,8 @@ def test_init_slots_cache_slots_collision(self, request): raise an error. In this test both nodes will say that the first slots block should be bound to different servers. """ - with patch.object(NodesManager, - 'create_redis_node') as create_redis_node: + with patch.object(NodesManager, "create_redis_node") as create_redis_node: + def create_mocked_redis_node(host, port, **kwargs): """ Helper function to return custom slots cache data from @@ -1873,14 +1957,14 @@ def create_mocked_redis_node(host, port, **kwargs): [ 0, 5460, - ['127.0.0.1', 7000], - ['127.0.0.1', 7003], + ["127.0.0.1", 7000], + ["127.0.0.1", 7003], ], [ 5461, 10922, - ['127.0.0.1', 7001], - ['127.0.0.1', 7004], + ["127.0.0.1", 7001], + ["127.0.0.1", 7004], ], ] @@ -1889,31 +1973,28 @@ def create_mocked_redis_node(host, port, **kwargs): [ 0, 5460, - ['127.0.0.1', 7001], - ['127.0.0.1', 7003], + ["127.0.0.1", 7001], + ["127.0.0.1", 7003], ], [ 5461, 10922, - ['127.0.0.1', 7000], - ['127.0.0.1', 7004], + ["127.0.0.1", 7000], + ["127.0.0.1", 7004], ], ] else: result = [] - r_node = Redis( - host=host, - port=port - ) + r_node = Redis(host=host, port=port) orig_execute_command = r_node.execute_command def execute_command(*args, **kwargs): - if args[0] == 'CLUSTER SLOTS': + if args[0] == "CLUSTER SLOTS": return result - elif args[1] == 'cluster-require-full-coverage': - return {'cluster-require-full-coverage': 'yes'} + elif args[1] == "cluster-require-full-coverage": + return {"cluster-require-full-coverage": "yes"} else: return orig_execute_command(*args, **kwargs) @@ -1923,12 +2004,12 @@ def execute_command(*args, **kwargs): create_redis_node.side_effect = create_mocked_redis_node with pytest.raises(RedisClusterException) as ex: - node_1 = ClusterNode('127.0.0.1', 7000) - node_2 = ClusterNode('127.0.0.1', 7001) + node_1 = ClusterNode("127.0.0.1", 7000) + node_2 = ClusterNode("127.0.0.1", 7001) RedisCluster(startup_nodes=[node_1, node_2]) assert str(ex.value).startswith( - "startup_nodes could not agree on a valid slots cache"), str( - ex.value) + "startup_nodes could not agree on a valid slots cache" + ), str(ex.value) def test_cluster_one_instance(self): """ @@ -1936,9 +2017,8 @@ def test_cluster_one_instance(self): be validated they work. """ node = ClusterNode(default_host, default_port) - cluster_slots = [[0, 16383, ['', default_port]]] - rc = get_mocked_redis_client(startup_nodes=[node], - cluster_slots=cluster_slots) + cluster_slots = [[0, 16383, ["", default_port]]] + rc = get_mocked_redis_client(startup_nodes=[node], cluster_slots=cluster_slots) n = rc.nodes_manager assert len(n.nodes_cache) == 1 @@ -1955,28 +2035,30 @@ def test_init_with_down_node(self): If I can't connect to one of the nodes, everything should still work. But if I can't connect to any of the nodes, exception should be thrown. """ - with patch.object(NodesManager, - 'create_redis_node') as create_redis_node: + with patch.object(NodesManager, "create_redis_node") as create_redis_node: + def create_mocked_redis_node(host, port, **kwargs): if port == 7000: - raise ConnectionError('mock connection error for 7000') + raise ConnectionError("mock connection error for 7000") r_node = Redis(host=host, port=port, decode_responses=True) def execute_command(*args, **kwargs): - if args[0] == 'CLUSTER SLOTS': + if args[0] == "CLUSTER SLOTS": return [ [ - 0, 8191, - ['127.0.0.1', 7001, 'node_1'], + 0, + 8191, + ["127.0.0.1", 7001, "node_1"], ], [ - 8192, 16383, - ['127.0.0.1', 7002, 'node_2'], - ] + 8192, + 16383, + ["127.0.0.1", 7002, "node_2"], + ], ] - elif args[1] == 'cluster-require-full-coverage': - return {'cluster-require-full-coverage': 'yes'} + elif args[1] == "cluster-require-full-coverage": + return {"cluster-require-full-coverage": "yes"} r_node.execute_command = execute_command @@ -1984,25 +2066,30 @@ def execute_command(*args, **kwargs): create_redis_node.side_effect = create_mocked_redis_node - node_1 = ClusterNode('127.0.0.1', 7000) - node_2 = ClusterNode('127.0.0.1', 7001) + node_1 = ClusterNode("127.0.0.1", 7000) + node_2 = ClusterNode("127.0.0.1", 7001) # If all startup nodes fail to connect, connection error should be # thrown with pytest.raises(RedisClusterException) as e: RedisCluster(startup_nodes=[node_1]) - assert 'Redis Cluster cannot be connected' in str(e.value) + assert "Redis Cluster cannot be connected" in str(e.value) - with patch.object(CommandsParser, 'initialize', - autospec=True) as cmd_parser_initialize: + with patch.object( + CommandsParser, "initialize", autospec=True + ) as cmd_parser_initialize: def cmd_init_mock(self, r): - self.commands = {'get': {'name': 'get', 'arity': 2, - 'flags': ['readonly', - 'fast'], - 'first_key_pos': 1, - 'last_key_pos': 1, - 'step_count': 1}} + self.commands = { + "get": { + "name": "get", + "arity": 2, + "flags": ["readonly", "fast"], + "first_key_pos": 1, + "last_key_pos": 1, + "step_count": 1, + } + } cmd_parser_initialize.side_effect = cmd_init_mock # When at least one startup node is reachable, the cluster @@ -2040,7 +2127,7 @@ def test_init_pubusub_without_specifying_node(self, r): should be determined based on the keyslot of the first command execution. """ - channel_name = 'foo' + channel_name = "foo" node = r.get_node_from_key(channel_name) p = r.pubsub() assert p.get_pubsub_node() is None @@ -2052,7 +2139,7 @@ def test_init_pubsub_with_a_non_existent_node(self, r): Test creation of pubsub instance with node that doesn't exists in the cluster. RedisClusterException should be raised. """ - node = ClusterNode('1.1.1.1', 1111) + node = ClusterNode("1.1.1.1", 1111) with pytest.raises(RedisClusterException): r.pubsub(node) @@ -2063,7 +2150,7 @@ def test_init_pubsub_with_a_non_existent_host_port(self, r): RedisClusterException should be raised. """ with pytest.raises(RedisClusterException): - r.pubsub(host='1.1.1.1', port=1111) + r.pubsub(host="1.1.1.1", port=1111) def test_init_pubsub_host_or_port(self, r): """ @@ -2071,7 +2158,7 @@ def test_init_pubsub_host_or_port(self, r): versa. DataError should be raised. """ with pytest.raises(DataError): - r.pubsub(host='localhost') + r.pubsub(host="localhost") with pytest.raises(DataError): r.pubsub(port=16379) @@ -2131,14 +2218,17 @@ def test_blocked_arguments(self, r): with pytest.raises(RedisClusterException) as ex: r.pipeline(transaction=True) - assert str(ex.value).startswith( - "transaction is deprecated in cluster mode") is True + assert ( + str(ex.value).startswith("transaction is deprecated in cluster mode") + is True + ) with pytest.raises(RedisClusterException) as ex: r.pipeline(shard_hint=True) - assert str(ex.value).startswith( - "shard_hint is deprecated in cluster mode") is True + assert ( + str(ex.value).startswith("shard_hint is deprecated in cluster mode") is True + ) def test_redis_cluster_pipeline(self, r): """ @@ -2147,7 +2237,7 @@ def test_redis_cluster_pipeline(self, r): with r.pipeline() as pipe: pipe.set("foo", "bar") pipe.get("foo") - assert pipe.execute() == [True, b'bar'] + assert pipe.execute() == [True, b"bar"] def test_mget_disabled(self, r): """ @@ -2155,7 +2245,7 @@ def test_mget_disabled(self, r): """ with r.pipeline() as pipe: with pytest.raises(RedisClusterException): - pipe.mget(['a']) + pipe.mget(["a"]) def test_mset_disabled(self, r): """ @@ -2163,7 +2253,7 @@ def test_mset_disabled(self, r): """ with r.pipeline() as pipe: with pytest.raises(RedisClusterException): - pipe.mset({'a': 1, 'b': 2}) + pipe.mset({"a": 1, "b": 2}) def test_rename_disabled(self, r): """ @@ -2171,7 +2261,7 @@ def test_rename_disabled(self, r): """ with r.pipeline(transaction=False) as pipe: with pytest.raises(RedisClusterException): - pipe.rename('a', 'b') + pipe.rename("a", "b") def test_renamenx_disabled(self, r): """ @@ -2179,15 +2269,15 @@ def test_renamenx_disabled(self, r): """ with r.pipeline(transaction=False) as pipe: with pytest.raises(RedisClusterException): - pipe.renamenx('a', 'b') + pipe.renamenx("a", "b") def test_delete_single(self, r): """ Test a single delete operation """ - r['a'] = 1 + r["a"] = 1 with r.pipeline(transaction=False) as pipe: - pipe.delete('a') + pipe.delete("a") assert pipe.execute() == [1] def test_multi_delete_unsupported(self, r): @@ -2195,10 +2285,10 @@ def test_multi_delete_unsupported(self, r): Test that multi delete operation is unsupported """ with r.pipeline(transaction=False) as pipe: - r['a'] = 1 - r['b'] = 2 + r["a"] = 1 + r["b"] = 2 with pytest.raises(RedisClusterException): - pipe.delete('a', 'b') + pipe.delete("a", "b") def test_brpoplpush_disabled(self, r): """ @@ -2293,41 +2383,40 @@ def test_multi_key_operation_with_a_single_slot(self, r): Test multi key operation with a single slot """ pipe = r.pipeline(transaction=False) - pipe.set('a{foo}', 1) - pipe.set('b{foo}', 2) - pipe.set('c{foo}', 3) - pipe.get('a{foo}') - pipe.get('b{foo}') - pipe.get('c{foo}') + pipe.set("a{foo}", 1) + pipe.set("b{foo}", 2) + pipe.set("c{foo}", 3) + pipe.get("a{foo}") + pipe.get("b{foo}") + pipe.get("c{foo}") res = pipe.execute() - assert res == [True, True, True, b'1', b'2', b'3'] + assert res == [True, True, True, b"1", b"2", b"3"] def test_multi_key_operation_with_multi_slots(self, r): """ Test multi key operation with more than one slot """ pipe = r.pipeline(transaction=False) - pipe.set('a{foo}', 1) - pipe.set('b{foo}', 2) - pipe.set('c{foo}', 3) - pipe.set('bar', 4) - pipe.set('bazz', 5) - pipe.get('a{foo}') - pipe.get('b{foo}') - pipe.get('c{foo}') - pipe.get('bar') - pipe.get('bazz') + pipe.set("a{foo}", 1) + pipe.set("b{foo}", 2) + pipe.set("c{foo}", 3) + pipe.set("bar", 4) + pipe.set("bazz", 5) + pipe.get("a{foo}") + pipe.get("b{foo}") + pipe.get("c{foo}") + pipe.get("bar") + pipe.get("bazz") res = pipe.execute() - assert res == [True, True, True, True, True, b'1', b'2', b'3', b'4', - b'5'] + assert res == [True, True, True, True, True, b"1", b"2", b"3", b"4", b"5"] def test_connection_error_not_raised(self, r): """ Test that the pipeline doesn't raise an error on connection error when raise_on_error=False """ - key = 'foo' + key = "foo" node = r.get_node_from_key(key, False) def raise_connection_error(): @@ -2345,7 +2434,7 @@ def test_connection_error_raised(self, r): Test that the pipeline raises an error on connection error when raise_on_error=True """ - key = 'foo' + key = "foo" node = r.get_node_from_key(key, False) def raise_connection_error(): @@ -2361,7 +2450,7 @@ def test_asking_error(self, r): """ Test redirection on ASK error """ - key = 'foo' + key = "foo" first_node = r.get_node_from_key(key, False) ask_node = None for node in r.get_nodes(): @@ -2369,8 +2458,7 @@ def test_asking_error(self, r): ask_node = node break if ask_node is None: - warnings.warn("skipping this test since the cluster has only one " - "node") + warnings.warn("skipping this test since the cluster has only one " "node") return ask_msg = f"{r.keyslot(key)} {ask_node.host}:{ask_node.port}" @@ -2379,11 +2467,11 @@ def raise_ask_error(): with r.pipeline() as pipe: mock_node_resp_func(first_node, raise_ask_error) - mock_node_resp(ask_node, 'MOCK_OK') + mock_node_resp(ask_node, "MOCK_OK") res = pipe.get(key).execute() assert first_node.redis_connection.connection.read_response.called assert ask_node.redis_connection.connection.read_response.called - assert res == ['MOCK_OK'] + assert res == ["MOCK_OK"] def test_empty_stack(self, r): """ @@ -2405,17 +2493,16 @@ def test_pipeline_readonly(self, r): """ On readonly mode, we supports get related stuff only. """ - r.readonly(target_nodes='all') - r.set('foo71', 'a1') # we assume this key is set on 127.0.0.1:7001 - r.zadd('foo88', - {'z1': 1}) # we assume this key is set on 127.0.0.1:7002 - r.zadd('foo88', {'z2': 4}) + r.readonly(target_nodes="all") + r.set("foo71", "a1") # we assume this key is set on 127.0.0.1:7001 + r.zadd("foo88", {"z1": 1}) # we assume this key is set on 127.0.0.1:7002 + r.zadd("foo88", {"z2": 4}) with r.pipeline() as readonly_pipe: - readonly_pipe.get('foo71').zrange('foo88', 0, 5, withscores=True) + readonly_pipe.get("foo71").zrange("foo88", 0, 5, withscores=True) assert readonly_pipe.execute() == [ - b'a1', - [(b'z1', 1.0), (b'z2', 4)], + b"a1", + [(b"z1", 1.0), (b"z2", 4)], ] def test_moved_redirection_on_slave_with_default(self, r): @@ -2423,8 +2510,8 @@ def test_moved_redirection_on_slave_with_default(self, r): On Pipeline, we redirected once and finally get from master with readonly client when data is completely moved. """ - key = 'bar' - r.set(key, 'foo') + key = "bar" + r.set(key, "foo") # set read_from_replicas to True r.read_from_replicas = True primary = r.get_node_from_key(key, False) @@ -2456,15 +2543,15 @@ def test_readonly_pipeline_from_readonly_client(self, request): """ # Create a cluster with reading from replications ro = _get_client(RedisCluster, request, read_from_replicas=True) - key = 'bar' - ro.set(key, 'foo') + key = "bar" + ro.set(key, "foo") import time + time.sleep(0.2) with ro.pipeline() as readonly_pipe: - mock_all_nodes_resp(ro, 'MOCK_OK') + mock_all_nodes_resp(ro, "MOCK_OK") assert readonly_pipe.read_from_replicas is True - assert readonly_pipe.get(key).get( - key).execute() == ['MOCK_OK', 'MOCK_OK'] + assert readonly_pipe.get(key).get(key).execute() == ["MOCK_OK", "MOCK_OK"] slot_nodes = ro.nodes_manager.slots_cache[ro.keyslot(key)] if len(slot_nodes) > 1: executed_on_replica = False diff --git a/tests/test_command_parser.py b/tests/test_command_parser.py index ba129ba673..ad29e69f37 100644 --- a/tests/test_command_parser.py +++ b/tests/test_command_parser.py @@ -7,56 +7,74 @@ class TestCommandsParser: def test_init_commands(self, r): commands_parser = CommandsParser(r) assert commands_parser.commands is not None - assert 'get' in commands_parser.commands + assert "get" in commands_parser.commands def test_get_keys_predetermined_key_location(self, r): commands_parser = CommandsParser(r) - args1 = ['GET', 'foo'] - args2 = ['OBJECT', 'encoding', 'foo'] - args3 = ['MGET', 'foo', 'bar', 'foobar'] - assert commands_parser.get_keys(r, *args1) == ['foo'] - assert commands_parser.get_keys(r, *args2) == ['foo'] - assert commands_parser.get_keys(r, *args3) == ['foo', 'bar', 'foobar'] + args1 = ["GET", "foo"] + args2 = ["OBJECT", "encoding", "foo"] + args3 = ["MGET", "foo", "bar", "foobar"] + assert commands_parser.get_keys(r, *args1) == ["foo"] + assert commands_parser.get_keys(r, *args2) == ["foo"] + assert commands_parser.get_keys(r, *args3) == ["foo", "bar", "foobar"] @pytest.mark.filterwarnings("ignore:ResponseError") def test_get_moveable_keys(self, r): commands_parser = CommandsParser(r) - args1 = ['EVAL', 'return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}', 2, 'key1', - 'key2', 'first', 'second'] - args2 = ['XREAD', 'COUNT', 2, b'STREAMS', 'mystream', 'writers', 0, 0] - args3 = ['ZUNIONSTORE', 'out', 2, 'zset1', 'zset2', 'WEIGHTS', 2, 3] - args4 = ['GEORADIUS', 'Sicily', 15, 37, 200, 'km', 'WITHCOORD', - b'STORE', 'out'] - args5 = ['MEMORY USAGE', 'foo'] - args6 = ['MIGRATE', '192.168.1.34', 6379, "", 0, 5000, b'KEYS', - 'key1', 'key2', 'key3'] - args7 = ['MIGRATE', '192.168.1.34', 6379, "key1", 0, 5000] - args8 = ['STRALGO', 'LCS', 'STRINGS', 'string_a', 'string_b'] - args9 = ['STRALGO', 'LCS', 'KEYS', 'key1', 'key2'] + args1 = [ + "EVAL", + "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}", + 2, + "key1", + "key2", + "first", + "second", + ] + args2 = ["XREAD", "COUNT", 2, b"STREAMS", "mystream", "writers", 0, 0] + args3 = ["ZUNIONSTORE", "out", 2, "zset1", "zset2", "WEIGHTS", 2, 3] + args4 = ["GEORADIUS", "Sicily", 15, 37, 200, "km", "WITHCOORD", b"STORE", "out"] + args5 = ["MEMORY USAGE", "foo"] + args6 = [ + "MIGRATE", + "192.168.1.34", + 6379, + "", + 0, + 5000, + b"KEYS", + "key1", + "key2", + "key3", + ] + args7 = ["MIGRATE", "192.168.1.34", 6379, "key1", 0, 5000] + args8 = ["STRALGO", "LCS", "STRINGS", "string_a", "string_b"] + args9 = ["STRALGO", "LCS", "KEYS", "key1", "key2"] - assert commands_parser.get_keys( - r, *args1).sort() == ['key1', 'key2'].sort() - assert commands_parser.get_keys( - r, *args2).sort() == ['mystream', 'writers'].sort() - assert commands_parser.get_keys( - r, *args3).sort() == ['out', 'zset1', 'zset2'].sort() - assert commands_parser.get_keys( - r, *args4).sort() == ['Sicily', 'out'].sort() - assert commands_parser.get_keys(r, *args5).sort() == ['foo'].sort() - assert commands_parser.get_keys( - r, *args6).sort() == ['key1', 'key2', 'key3'].sort() - assert commands_parser.get_keys(r, *args7).sort() == ['key1'].sort() + assert commands_parser.get_keys(r, *args1).sort() == ["key1", "key2"].sort() + assert ( + commands_parser.get_keys(r, *args2).sort() == ["mystream", "writers"].sort() + ) + assert ( + commands_parser.get_keys(r, *args3).sort() + == ["out", "zset1", "zset2"].sort() + ) + assert commands_parser.get_keys(r, *args4).sort() == ["Sicily", "out"].sort() + assert commands_parser.get_keys(r, *args5).sort() == ["foo"].sort() + assert ( + commands_parser.get_keys(r, *args6).sort() + == ["key1", "key2", "key3"].sort() + ) + assert commands_parser.get_keys(r, *args7).sort() == ["key1"].sort() assert commands_parser.get_keys(r, *args8) is None - assert commands_parser.get_keys( - r, *args9).sort() == ['key1', 'key2'].sort() + assert commands_parser.get_keys(r, *args9).sort() == ["key1", "key2"].sort() def test_get_pubsub_keys(self, r): commands_parser = CommandsParser(r) - args1 = ['PUBLISH', 'foo', 'bar'] - args2 = ['PUBSUB NUMSUB', 'foo1', 'foo2', 'foo3'] - args3 = ['PUBSUB channels', '*'] - args4 = ['SUBSCRIBE', 'foo1', 'foo2', 'foo3'] - assert commands_parser.get_keys(r, *args1) == ['foo'] - assert commands_parser.get_keys(r, *args2) == ['foo1', 'foo2', 'foo3'] - assert commands_parser.get_keys(r, *args3) == ['*'] - assert commands_parser.get_keys(r, *args4) == ['foo1', 'foo2', 'foo3'] + args1 = ["PUBLISH", "foo", "bar"] + args2 = ["PUBSUB NUMSUB", "foo1", "foo2", "foo3"] + args3 = ["PUBSUB channels", "*"] + args4 = ["SUBSCRIBE", "foo1", "foo2", "foo3"] + assert commands_parser.get_keys(r, *args1) == ["foo"] + assert commands_parser.get_keys(r, *args2) == ["foo1", "foo2", "foo3"] + assert commands_parser.get_keys(r, *args3) == ["*"] + assert commands_parser.get_keys(r, *args4) == ["foo1", "foo2", "foo3"] diff --git a/tests/test_commands.py b/tests/test_commands.py index 444a163489..1eb35f8673 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,19 +1,20 @@ import binascii import datetime -import pytest import re -import redis import time from string import ascii_letters -from redis.client import parse_info +import pytest + +import redis from redis import exceptions +from redis.client import parse_info from .conftest import ( _get_client, + skip_if_redis_enterprise, skip_if_server_version_gte, skip_if_server_version_lt, - skip_if_redis_enterprise, skip_unless_arch_bits, ) @@ -21,21 +22,22 @@ @pytest.fixture() def slowlog(request, r): current_config = r.config_get() - old_slower_than_value = current_config['slowlog-log-slower-than'] - old_max_legnth_value = current_config['slowlog-max-len'] + old_slower_than_value = current_config["slowlog-log-slower-than"] + old_max_legnth_value = current_config["slowlog-max-len"] def cleanup(): - r.config_set('slowlog-log-slower-than', old_slower_than_value) - r.config_set('slowlog-max-len', old_max_legnth_value) + r.config_set("slowlog-log-slower-than", old_slower_than_value) + r.config_set("slowlog-max-len", old_max_legnth_value) + request.addfinalizer(cleanup) - r.config_set('slowlog-log-slower-than', 0) - r.config_set('slowlog-max-len', 128) + r.config_set("slowlog-log-slower-than", 0) + r.config_set("slowlog-max-len", 128) def redis_server_time(client): seconds, milliseconds = client.time() - timestamp = float(f'{seconds}.{milliseconds}') + timestamp = float(f"{seconds}.{milliseconds}") return datetime.datetime.fromtimestamp(timestamp) @@ -54,19 +56,19 @@ class TestResponseCallbacks: def test_response_callbacks(self, r): assert r.response_callbacks == redis.Redis.RESPONSE_CALLBACKS assert id(r.response_callbacks) != id(redis.Redis.RESPONSE_CALLBACKS) - r.set_response_callback('GET', lambda x: 'static') - r['a'] = 'foo' - assert r['a'] == 'static' + r.set_response_callback("GET", lambda x: "static") + r["a"] = "foo" + assert r["a"] == "static" def test_case_insensitive_command_names(self, r): - assert r.response_callbacks['del'] == r.response_callbacks['DEL'] + assert r.response_callbacks["del"] == r.response_callbacks["DEL"] class TestRedisCommands: def test_command_on_invalid_key_type(self, r): - r.lpush('a', '1') + r.lpush("a", "1") with pytest.raises(redis.ResponseError): - r['a'] + r["a"] # SERVER INFORMATION @pytest.mark.onlynoncluster @@ -74,20 +76,20 @@ def test_command_on_invalid_key_type(self, r): def test_acl_cat_no_category(self, r): categories = r.acl_cat() assert isinstance(categories, list) - assert 'read' in categories + assert "read" in categories @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_cat_with_category(self, r): - commands = r.acl_cat('read') + commands = r.acl_cat("read") assert isinstance(commands, list) - assert 'get' in commands + assert "get" in commands @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_deluser(self, r, request): - username = 'redis-py-user' + username = "redis-py-user" def teardown(): r.acl_deluser(username) @@ -99,7 +101,7 @@ def teardown(): assert r.acl_deluser(username) == 1 # now, a group of users - users = [f'bogususer_{r}' for r in range(0, 5)] + users = [f"bogususer_{r}" for r in range(0, 5)] for u in users: r.acl_setuser(u, enabled=False, reset=True) assert r.acl_deluser(*users) > 1 @@ -117,7 +119,7 @@ def test_acl_genpass(self, r): assert isinstance(password, str) with pytest.raises(exceptions.DataError): - r.acl_genpass('value') + r.acl_genpass("value") r.acl_genpass(-5) r.acl_genpass(5555) @@ -128,90 +130,109 @@ def test_acl_genpass(self, r): @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_getuser_setuser(self, r, request): - username = 'redis-py-user' + username = "redis-py-user" def teardown(): r.acl_deluser(username) + request.addfinalizer(teardown) # test enabled=False assert r.acl_setuser(username, enabled=False, reset=True) acl = r.acl_getuser(username) - assert acl['categories'] == ['-@all'] - assert acl['commands'] == [] - assert acl['keys'] == [] - assert acl['passwords'] == [] - assert 'off' in acl['flags'] - assert acl['enabled'] is False + assert acl["categories"] == ["-@all"] + assert acl["commands"] == [] + assert acl["keys"] == [] + assert acl["passwords"] == [] + assert "off" in acl["flags"] + assert acl["enabled"] is False # test nopass=True assert r.acl_setuser(username, enabled=True, reset=True, nopass=True) acl = r.acl_getuser(username) - assert acl['categories'] == ['-@all'] - assert acl['commands'] == [] - assert acl['keys'] == [] - assert acl['passwords'] == [] - assert 'on' in acl['flags'] - assert 'nopass' in acl['flags'] - assert acl['enabled'] is True + assert acl["categories"] == ["-@all"] + assert acl["commands"] == [] + assert acl["keys"] == [] + assert acl["passwords"] == [] + assert "on" in acl["flags"] + assert "nopass" in acl["flags"] + assert acl["enabled"] is True # test all args - assert r.acl_setuser(username, enabled=True, reset=True, - passwords=['+pass1', '+pass2'], - categories=['+set', '+@hash', '-geo'], - commands=['+get', '+mget', '-hset'], - keys=['cache:*', 'objects:*']) + assert r.acl_setuser( + username, + enabled=True, + reset=True, + passwords=["+pass1", "+pass2"], + categories=["+set", "+@hash", "-geo"], + commands=["+get", "+mget", "-hset"], + keys=["cache:*", "objects:*"], + ) acl = r.acl_getuser(username) - assert set(acl['categories']) == {'-@all', '+@set', '+@hash'} - assert set(acl['commands']) == {'+get', '+mget', '-hset'} - assert acl['enabled'] is True - assert 'on' in acl['flags'] - assert set(acl['keys']) == {b'cache:*', b'objects:*'} - assert len(acl['passwords']) == 2 + assert set(acl["categories"]) == {"-@all", "+@set", "+@hash"} + assert set(acl["commands"]) == {"+get", "+mget", "-hset"} + assert acl["enabled"] is True + assert "on" in acl["flags"] + assert set(acl["keys"]) == {b"cache:*", b"objects:*"} + assert len(acl["passwords"]) == 2 # test reset=False keeps existing ACL and applies new ACL on top - assert r.acl_setuser(username, enabled=True, reset=True, - passwords=['+pass1'], - categories=['+@set'], - commands=['+get'], - keys=['cache:*']) - assert r.acl_setuser(username, enabled=True, - passwords=['+pass2'], - categories=['+@hash'], - commands=['+mget'], - keys=['objects:*']) + assert r.acl_setuser( + username, + enabled=True, + reset=True, + passwords=["+pass1"], + categories=["+@set"], + commands=["+get"], + keys=["cache:*"], + ) + assert r.acl_setuser( + username, + enabled=True, + passwords=["+pass2"], + categories=["+@hash"], + commands=["+mget"], + keys=["objects:*"], + ) acl = r.acl_getuser(username) - assert set(acl['categories']) == {'-@all', '+@set', '+@hash'} - assert set(acl['commands']) == {'+get', '+mget'} - assert acl['enabled'] is True - assert 'on' in acl['flags'] - assert set(acl['keys']) == {b'cache:*', b'objects:*'} - assert len(acl['passwords']) == 2 + assert set(acl["categories"]) == {"-@all", "+@set", "+@hash"} + assert set(acl["commands"]) == {"+get", "+mget"} + assert acl["enabled"] is True + assert "on" in acl["flags"] + assert set(acl["keys"]) == {b"cache:*", b"objects:*"} + assert len(acl["passwords"]) == 2 # test removal of passwords - assert r.acl_setuser(username, enabled=True, reset=True, - passwords=['+pass1', '+pass2']) - assert len(r.acl_getuser(username)['passwords']) == 2 - assert r.acl_setuser(username, enabled=True, - passwords=['-pass2']) - assert len(r.acl_getuser(username)['passwords']) == 1 + assert r.acl_setuser( + username, enabled=True, reset=True, passwords=["+pass1", "+pass2"] + ) + assert len(r.acl_getuser(username)["passwords"]) == 2 + assert r.acl_setuser(username, enabled=True, passwords=["-pass2"]) + assert len(r.acl_getuser(username)["passwords"]) == 1 # Resets and tests that hashed passwords are set properly. - hashed_password = ('5e884898da28047151d0e56f8dc629' - '2773603d0d6aabbdd62a11ef721d1542d8') - assert r.acl_setuser(username, enabled=True, reset=True, - hashed_passwords=['+' + hashed_password]) + hashed_password = ( + "5e884898da28047151d0e56f8dc629" "2773603d0d6aabbdd62a11ef721d1542d8" + ) + assert r.acl_setuser( + username, enabled=True, reset=True, hashed_passwords=["+" + hashed_password] + ) acl = r.acl_getuser(username) - assert acl['passwords'] == [hashed_password] + assert acl["passwords"] == [hashed_password] # test removal of hashed passwords - assert r.acl_setuser(username, enabled=True, reset=True, - hashed_passwords=['+' + hashed_password], - passwords=['+pass1']) - assert len(r.acl_getuser(username)['passwords']) == 2 - assert r.acl_setuser(username, enabled=True, - hashed_passwords=['-' + hashed_password]) - assert len(r.acl_getuser(username)['passwords']) == 1 + assert r.acl_setuser( + username, + enabled=True, + reset=True, + hashed_passwords=["+" + hashed_password], + passwords=["+pass1"], + ) + assert len(r.acl_getuser(username)["passwords"]) == 2 + assert r.acl_setuser( + username, enabled=True, hashed_passwords=["-" + hashed_password] + ) + assert len(r.acl_getuser(username)["passwords"]) == 1 @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @@ -224,10 +245,11 @@ def test_acl_help(self, r): @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_list(self, r, request): - username = 'redis-py-user' + username = "redis-py-user" def teardown(): r.acl_deluser(username) + request.addfinalizer(teardown) assert r.acl_setuser(username, enabled=False, reset=True) @@ -238,77 +260,86 @@ def teardown(): @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_log(self, r, request): - username = 'redis-py-user' + username = "redis-py-user" def teardown(): r.acl_deluser(username) request.addfinalizer(teardown) - r.acl_setuser(username, enabled=True, reset=True, - commands=['+get', '+set', '+select'], - keys=['cache:*'], nopass=True) + r.acl_setuser( + username, + enabled=True, + reset=True, + commands=["+get", "+set", "+select"], + keys=["cache:*"], + nopass=True, + ) r.acl_log_reset() - user_client = _get_client(redis.Redis, request, flushdb=False, - username=username) + user_client = _get_client( + redis.Redis, request, flushdb=False, username=username + ) # Valid operation and key - assert user_client.set('cache:0', 1) - assert user_client.get('cache:0') == b'1' + assert user_client.set("cache:0", 1) + assert user_client.get("cache:0") == b"1" # Invalid key with pytest.raises(exceptions.NoPermissionError): - user_client.get('violated_cache:0') + user_client.get("violated_cache:0") # Invalid operation with pytest.raises(exceptions.NoPermissionError): - user_client.hset('cache:0', 'hkey', 'hval') + user_client.hset("cache:0", "hkey", "hval") assert isinstance(r.acl_log(), list) assert len(r.acl_log()) == 2 assert len(r.acl_log(count=1)) == 1 assert isinstance(r.acl_log()[0], dict) - assert 'client-info' in r.acl_log(count=1)[0] + assert "client-info" in r.acl_log(count=1)[0] assert r.acl_log_reset() @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_categories_without_prefix_fails(self, r, request): - username = 'redis-py-user' + username = "redis-py-user" def teardown(): r.acl_deluser(username) + request.addfinalizer(teardown) with pytest.raises(exceptions.DataError): - r.acl_setuser(username, categories=['list']) + r.acl_setuser(username, categories=["list"]) @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_commands_without_prefix_fails(self, r, request): - username = 'redis-py-user' + username = "redis-py-user" def teardown(): r.acl_deluser(username) + request.addfinalizer(teardown) with pytest.raises(exceptions.DataError): - r.acl_setuser(username, commands=['get']) + r.acl_setuser(username, commands=["get"]) @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request): - username = 'redis-py-user' + username = "redis-py-user" def teardown(): r.acl_deluser(username) + request.addfinalizer(teardown) with pytest.raises(exceptions.DataError): - r.acl_setuser(username, passwords='+mypass', nopass=True) + r.acl_setuser(username, passwords="+mypass", nopass=True) @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @@ -327,36 +358,36 @@ def test_acl_whoami(self, r): def test_client_list(self, r): clients = r.client_list() assert isinstance(clients[0], dict) - assert 'addr' in clients[0] + assert "addr" in clients[0] @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_client_info(self, r): info = r.client_info() assert isinstance(info, dict) - assert 'addr' in info + assert "addr" in info @pytest.mark.onlynoncluster - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_client_list_types_not_replica(self, r): with pytest.raises(exceptions.RedisError): - r.client_list(_type='not a client type') - for client_type in ['normal', 'master', 'pubsub']: + r.client_list(_type="not a client type") + for client_type in ["normal", "master", "pubsub"]: clients = r.client_list(_type=client_type) assert isinstance(clients, list) @skip_if_redis_enterprise def test_client_list_replica(self, r): - clients = r.client_list(_type='replica') + clients = r.client_list(_type="replica") assert isinstance(clients, list) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_client_list_client_id(self, r, request): clients = r.client_list() - clients = r.client_list(client_id=[clients[0]['id']]) + clients = r.client_list(client_id=[clients[0]["id"]]) assert len(clients) == 1 - assert 'addr' in clients[0] + assert "addr" in clients[0] # testing multiple client ids _get_client(redis.Redis, request, flushdb=False) @@ -366,19 +397,19 @@ def test_client_list_client_id(self, r, request): assert len(clients_listed) > 1 @pytest.mark.onlynoncluster - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_client_id(self, r): assert r.client_id() > 0 @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_client_trackinginfo(self, r): res = r.client_trackinginfo() assert len(res) > 2 - assert 'prefixes' in res + assert "prefixes" in res @pytest.mark.onlynoncluster - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_client_unblock(self, r): myid = r.client_id() assert not r.client_unblock(myid) @@ -386,36 +417,42 @@ def test_client_unblock(self, r): assert not r.client_unblock(myid, error=False) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.6.9') + @skip_if_server_version_lt("2.6.9") def test_client_getname(self, r): assert r.client_getname() is None @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.6.9') + @skip_if_server_version_lt("2.6.9") def test_client_setname(self, r): - assert r.client_setname('redis_py_test') - assert r.client_getname() == 'redis_py_test' + assert r.client_setname("redis_py_test") + assert r.client_getname() == "redis_py_test" @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.6.9') + @skip_if_server_version_lt("2.6.9") def test_client_kill(self, r, r2): - r.client_setname('redis-py-c1') - r2.client_setname('redis-py-c2') - clients = [client for client in r.client_list() - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + r.client_setname("redis-py-c1") + r2.client_setname("redis-py-c2") + clients = [ + client + for client in r.client_list() + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 2 - clients_by_name = {client.get('name'): client for client in clients} + clients_by_name = {client.get("name"): client for client in clients} - client_addr = clients_by_name['redis-py-c2'].get('addr') + client_addr = clients_by_name["redis-py-c2"].get("addr") assert r.client_kill(client_addr) is True - clients = [client for client in r.client_list() - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + clients = [ + client + for client in r.client_list() + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 1 - assert clients[0].get('name') == 'redis-py-c1' + assert clients[0].get("name") == "redis-py-c1" - @skip_if_server_version_lt('2.8.12') + @skip_if_server_version_lt("2.8.12") def test_client_kill_filter_invalid_params(self, r): # empty with pytest.raises(exceptions.DataError): @@ -430,110 +467,130 @@ def test_client_kill_filter_invalid_params(self, r): r.client_kill_filter(_type="caster") @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.12') + @skip_if_server_version_lt("2.8.12") def test_client_kill_filter_by_id(self, r, r2): - r.client_setname('redis-py-c1') - r2.client_setname('redis-py-c2') - clients = [client for client in r.client_list() - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + r.client_setname("redis-py-c1") + r2.client_setname("redis-py-c2") + clients = [ + client + for client in r.client_list() + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 2 - clients_by_name = {client.get('name'): client for client in clients} + clients_by_name = {client.get("name"): client for client in clients} - client_2_id = clients_by_name['redis-py-c2'].get('id') + client_2_id = clients_by_name["redis-py-c2"].get("id") resp = r.client_kill_filter(_id=client_2_id) assert resp == 1 - clients = [client for client in r.client_list() - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + clients = [ + client + for client in r.client_list() + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 1 - assert clients[0].get('name') == 'redis-py-c1' + assert clients[0].get("name") == "redis-py-c1" @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.12') + @skip_if_server_version_lt("2.8.12") def test_client_kill_filter_by_addr(self, r, r2): - r.client_setname('redis-py-c1') - r2.client_setname('redis-py-c2') - clients = [client for client in r.client_list() - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + r.client_setname("redis-py-c1") + r2.client_setname("redis-py-c2") + clients = [ + client + for client in r.client_list() + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 2 - clients_by_name = {client.get('name'): client for client in clients} + clients_by_name = {client.get("name"): client for client in clients} - client_2_addr = clients_by_name['redis-py-c2'].get('addr') + client_2_addr = clients_by_name["redis-py-c2"].get("addr") resp = r.client_kill_filter(addr=client_2_addr) assert resp == 1 - clients = [client for client in r.client_list() - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + clients = [ + client + for client in r.client_list() + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 1 - assert clients[0].get('name') == 'redis-py-c1' + assert clients[0].get("name") == "redis-py-c1" - @skip_if_server_version_lt('2.6.9') + @skip_if_server_version_lt("2.6.9") def test_client_list_after_client_setname(self, r): - r.client_setname('redis_py_test') + r.client_setname("redis_py_test") clients = r.client_list() # we don't know which client ours will be - assert 'redis_py_test' in [c['name'] for c in clients] + assert "redis_py_test" in [c["name"] for c in clients] - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_client_kill_filter_by_laddr(self, r, r2): - r.client_setname('redis-py-c1') - r2.client_setname('redis-py-c2') - clients = [client for client in r.client_list() - if client.get('name') in ['redis-py-c1', 'redis-py-c2']] + r.client_setname("redis-py-c1") + r2.client_setname("redis-py-c2") + clients = [ + client + for client in r.client_list() + if client.get("name") in ["redis-py-c1", "redis-py-c2"] + ] assert len(clients) == 2 - clients_by_name = {client.get('name'): client for client in clients} + clients_by_name = {client.get("name"): client for client in clients} - client_2_addr = clients_by_name['redis-py-c2'].get('laddr') + client_2_addr = clients_by_name["redis-py-c2"].get("laddr") assert r.client_kill_filter(laddr=client_2_addr) - @skip_if_server_version_lt('6.0.0') + @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_client_kill_filter_by_user(self, r, request): - killuser = 'user_to_kill' - r.acl_setuser(killuser, enabled=True, reset=True, - commands=['+get', '+set', '+select'], - keys=['cache:*'], nopass=True) + killuser = "user_to_kill" + r.acl_setuser( + killuser, + enabled=True, + reset=True, + commands=["+get", "+set", "+select"], + keys=["cache:*"], + nopass=True, + ) _get_client(redis.Redis, request, flushdb=False, username=killuser) r.client_kill_filter(user=killuser) clients = r.client_list() for c in clients: - assert c['user'] != killuser + assert c["user"] != killuser r.acl_deluser(killuser) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.9.50') + @skip_if_server_version_lt("2.9.50") @skip_if_redis_enterprise def test_client_pause(self, r): assert r.client_pause(1) assert r.client_pause(timeout=1) with pytest.raises(exceptions.RedisError): - r.client_pause(timeout='not an integer') + r.client_pause(timeout="not an integer") @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") @skip_if_redis_enterprise def test_client_unpause(self, r): - assert r.client_unpause() == b'OK' + assert r.client_unpause() == b"OK" @pytest.mark.onlynoncluster - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_client_reply(self, r, r_timeout): - assert r_timeout.client_reply('ON') == b'OK' + assert r_timeout.client_reply("ON") == b"OK" with pytest.raises(exceptions.TimeoutError): - r_timeout.client_reply('OFF') + r_timeout.client_reply("OFF") - r_timeout.client_reply('SKIP') + r_timeout.client_reply("SKIP") - assert r_timeout.set('foo', 'bar') + assert r_timeout.set("foo", "bar") # validate it was set - assert r.get('foo') == b'bar' + assert r.get("foo") == b"bar" @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.0.0') + @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_client_getredir(self, r): assert isinstance(r.client_getredir(), int) @@ -549,37 +606,37 @@ def test_config_get(self, r): @skip_if_redis_enterprise def test_config_resetstat(self, r): r.ping() - prior_commands_processed = int(r.info()['total_commands_processed']) + prior_commands_processed = int(r.info()["total_commands_processed"]) assert prior_commands_processed >= 1 r.config_resetstat() - reset_commands_processed = int(r.info()['total_commands_processed']) + reset_commands_processed = int(r.info()["total_commands_processed"]) assert reset_commands_processed < prior_commands_processed @skip_if_redis_enterprise def test_config_set(self, r): - r.config_set('timeout', 70) - assert r.config_get()['timeout'] == '70' - assert r.config_set('timeout', 0) - assert r.config_get()['timeout'] == '0' + r.config_set("timeout", 70) + assert r.config_get()["timeout"] == "70" + assert r.config_set("timeout", 0) + assert r.config_get()["timeout"] == "0" @pytest.mark.onlynoncluster def test_dbsize(self, r): - r['a'] = 'foo' - r['b'] = 'bar' + r["a"] = "foo" + r["b"] = "bar" assert r.dbsize() == 2 @pytest.mark.onlynoncluster def test_echo(self, r): - assert r.echo('foo bar') == b'foo bar' + assert r.echo("foo bar") == b"foo bar" @pytest.mark.onlynoncluster def test_info(self, r): - r['a'] = 'foo' - r['b'] = 'bar' + r["a"] = "foo" + r["b"] = "bar" info = r.info() assert isinstance(info, dict) - assert 'arch_bits' in info.keys() - assert 'redis_version' in info.keys() + assert "arch_bits" in info.keys() + assert "redis_version" in info.keys() @pytest.mark.onlynoncluster @skip_if_redis_enterprise @@ -587,20 +644,20 @@ def test_lastsave(self, r): assert isinstance(r.lastsave(), datetime.datetime) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_lolwut(self, r): - lolwut = r.lolwut().decode('utf-8') - assert 'Redis ver.' in lolwut + lolwut = r.lolwut().decode("utf-8") + assert "Redis ver." in lolwut - lolwut = r.lolwut(5, 6, 7, 8).decode('utf-8') - assert 'Redis ver.' in lolwut + lolwut = r.lolwut(5, 6, 7, 8).decode("utf-8") + assert "Redis ver." in lolwut def test_object(self, r): - r['a'] = 'foo' - assert isinstance(r.object('refcount', 'a'), int) - assert isinstance(r.object('idletime', 'a'), int) - assert r.object('encoding', 'a') in (b'raw', b'embstr') - assert r.object('idletime', 'invalid-key') is None + r["a"] = "foo" + assert isinstance(r.object("refcount", "a"), int) + assert isinstance(r.object("idletime", "a"), int) + assert r.object("encoding", "a") in (b"raw", b"embstr") + assert r.object("idletime", "invalid-key") is None def test_ping(self, r): assert r.ping() @@ -612,36 +669,34 @@ def test_quit(self, r): @pytest.mark.onlynoncluster def test_slowlog_get(self, r, slowlog): assert r.slowlog_reset() - unicode_string = chr(3456) + 'abcd' + chr(3421) + unicode_string = chr(3456) + "abcd" + chr(3421) r.get(unicode_string) slowlog = r.slowlog_get() assert isinstance(slowlog, list) - commands = [log['command'] for log in slowlog] + commands = [log["command"] for log in slowlog] - get_command = b' '.join((b'GET', unicode_string.encode('utf-8'))) + get_command = b" ".join((b"GET", unicode_string.encode("utf-8"))) assert get_command in commands - assert b'SLOWLOG RESET' in commands + assert b"SLOWLOG RESET" in commands # the order should be ['GET ', 'SLOWLOG RESET'], # but if other clients are executing commands at the same time, there # could be commands, before, between, or after, so just check that # the two we care about are in the appropriate order. - assert commands.index(get_command) < commands.index(b'SLOWLOG RESET') + assert commands.index(get_command) < commands.index(b"SLOWLOG RESET") # make sure other attributes are typed correctly - assert isinstance(slowlog[0]['start_time'], int) - assert isinstance(slowlog[0]['duration'], int) + assert isinstance(slowlog[0]["start_time"], int) + assert isinstance(slowlog[0]["duration"], int) # Mock result if we didn't get slowlog complexity info. - if 'complexity' not in slowlog[0]: + if "complexity" not in slowlog[0]: # monkey patch parse_response() COMPLEXITY_STATEMENT = "Complexity info: N:4712,M:3788" old_parse_response = r.parse_response def parse_response(connection, command_name, **options): - if command_name != 'SLOWLOG GET': - return old_parse_response(connection, - command_name, - **options) + if command_name != "SLOWLOG GET": + return old_parse_response(connection, command_name, **options) responses = connection.read_response() for response in responses: # Complexity info stored as fourth item in list @@ -653,10 +708,10 @@ def parse_response(connection, command_name, **options): # test slowlog = r.slowlog_get() assert isinstance(slowlog, list) - commands = [log['command'] for log in slowlog] + commands = [log["command"] for log in slowlog] assert get_command in commands idx = commands.index(get_command) - assert slowlog[idx]['complexity'] == COMPLEXITY_STATEMENT + assert slowlog[idx]["complexity"] == COMPLEXITY_STATEMENT # tear down monkeypatch r.parse_response = old_parse_response @@ -664,7 +719,7 @@ def parse_response(connection, command_name, **options): @pytest.mark.onlynoncluster def test_slowlog_get_limit(self, r, slowlog): assert r.slowlog_reset() - r.get('foo') + r.get("foo") slowlog = r.slowlog_get(1) assert isinstance(slowlog, list) # only one command, based on the number we passed to slowlog_get() @@ -672,10 +727,10 @@ def test_slowlog_get_limit(self, r, slowlog): @pytest.mark.onlynoncluster def test_slowlog_length(self, r, slowlog): - r.get('foo') + r.get("foo") assert isinstance(r.slowlog_len(), int) - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_time(self, r): t = r.time() assert len(t) == 2 @@ -690,104 +745,104 @@ def test_bgsave(self, r): # BASIC KEY COMMANDS def test_append(self, r): - assert r.append('a', 'a1') == 2 - assert r['a'] == b'a1' - assert r.append('a', 'a2') == 4 - assert r['a'] == b'a1a2' + assert r.append("a", "a1") == 2 + assert r["a"] == b"a1" + assert r.append("a", "a2") == 4 + assert r["a"] == b"a1a2" - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_bitcount(self, r): - r.setbit('a', 5, True) - assert r.bitcount('a') == 1 - r.setbit('a', 6, True) - assert r.bitcount('a') == 2 - r.setbit('a', 5, False) - assert r.bitcount('a') == 1 - r.setbit('a', 9, True) - r.setbit('a', 17, True) - r.setbit('a', 25, True) - r.setbit('a', 33, True) - assert r.bitcount('a') == 5 - assert r.bitcount('a', 0, -1) == 5 - assert r.bitcount('a', 2, 3) == 2 - assert r.bitcount('a', 2, -1) == 3 - assert r.bitcount('a', -2, -1) == 2 - assert r.bitcount('a', 1, 1) == 1 - - @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.6.0') + r.setbit("a", 5, True) + assert r.bitcount("a") == 1 + r.setbit("a", 6, True) + assert r.bitcount("a") == 2 + r.setbit("a", 5, False) + assert r.bitcount("a") == 1 + r.setbit("a", 9, True) + r.setbit("a", 17, True) + r.setbit("a", 25, True) + r.setbit("a", 33, True) + assert r.bitcount("a") == 5 + assert r.bitcount("a", 0, -1) == 5 + assert r.bitcount("a", 2, 3) == 2 + assert r.bitcount("a", 2, -1) == 3 + assert r.bitcount("a", -2, -1) == 2 + assert r.bitcount("a", 1, 1) == 1 + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("2.6.0") def test_bitop_not_empty_string(self, r): - r['a'] = '' - r.bitop('not', 'r', 'a') - assert r.get('r') is None + r["a"] = "" + r.bitop("not", "r", "a") + assert r.get("r") is None @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_bitop_not(self, r): - test_str = b'\xAA\x00\xFF\x55' + test_str = b"\xAA\x00\xFF\x55" correct = ~0xAA00FF55 & 0xFFFFFFFF - r['a'] = test_str - r.bitop('not', 'r', 'a') - assert int(binascii.hexlify(r['r']), 16) == correct + r["a"] = test_str + r.bitop("not", "r", "a") + assert int(binascii.hexlify(r["r"]), 16) == correct @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_bitop_not_in_place(self, r): - test_str = b'\xAA\x00\xFF\x55' + test_str = b"\xAA\x00\xFF\x55" correct = ~0xAA00FF55 & 0xFFFFFFFF - r['a'] = test_str - r.bitop('not', 'a', 'a') - assert int(binascii.hexlify(r['a']), 16) == correct + r["a"] = test_str + r.bitop("not", "a", "a") + assert int(binascii.hexlify(r["a"]), 16) == correct @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_bitop_single_string(self, r): - test_str = b'\x01\x02\xFF' - r['a'] = test_str - r.bitop('and', 'res1', 'a') - r.bitop('or', 'res2', 'a') - r.bitop('xor', 'res3', 'a') - assert r['res1'] == test_str - assert r['res2'] == test_str - assert r['res3'] == test_str + test_str = b"\x01\x02\xFF" + r["a"] = test_str + r.bitop("and", "res1", "a") + r.bitop("or", "res2", "a") + r.bitop("xor", "res3", "a") + assert r["res1"] == test_str + assert r["res2"] == test_str + assert r["res3"] == test_str @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_bitop_string_operands(self, r): - r['a'] = b'\x01\x02\xFF\xFF' - r['b'] = b'\x01\x02\xFF' - r.bitop('and', 'res1', 'a', 'b') - r.bitop('or', 'res2', 'a', 'b') - r.bitop('xor', 'res3', 'a', 'b') - assert int(binascii.hexlify(r['res1']), 16) == 0x0102FF00 - assert int(binascii.hexlify(r['res2']), 16) == 0x0102FFFF - assert int(binascii.hexlify(r['res3']), 16) == 0x000000FF + r["a"] = b"\x01\x02\xFF\xFF" + r["b"] = b"\x01\x02\xFF" + r.bitop("and", "res1", "a", "b") + r.bitop("or", "res2", "a", "b") + r.bitop("xor", "res3", "a", "b") + assert int(binascii.hexlify(r["res1"]), 16) == 0x0102FF00 + assert int(binascii.hexlify(r["res2"]), 16) == 0x0102FFFF + assert int(binascii.hexlify(r["res3"]), 16) == 0x000000FF @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.7') + @skip_if_server_version_lt("2.8.7") def test_bitpos(self, r): - key = 'key:bitpos' - r.set(key, b'\xff\xf0\x00') + key = "key:bitpos" + r.set(key, b"\xff\xf0\x00") assert r.bitpos(key, 0) == 12 assert r.bitpos(key, 0, 2, -1) == 16 assert r.bitpos(key, 0, -2, -1) == 12 - r.set(key, b'\x00\xff\xf0') + r.set(key, b"\x00\xff\xf0") assert r.bitpos(key, 1, 0) == 8 assert r.bitpos(key, 1, 1) == 8 - r.set(key, b'\x00\x00\x00') + r.set(key, b"\x00\x00\x00") assert r.bitpos(key, 1) == -1 - @skip_if_server_version_lt('2.8.7') + @skip_if_server_version_lt("2.8.7") def test_bitpos_wrong_arguments(self, r): - key = 'key:bitpos:wrong:args' - r.set(key, b'\xff\xf0\x00') + key = "key:bitpos:wrong:args" + r.set(key, b"\xff\xf0\x00") with pytest.raises(exceptions.RedisError): r.bitpos(key, 0, end=1) == 12 with pytest.raises(exceptions.RedisError): r.bitpos(key, 7) == 12 @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_copy(self, r): assert r.copy("a", "b") == 0 r.set("a", "foo") @@ -796,7 +851,7 @@ def test_copy(self, r): assert r.get("b") == b"foo" @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_copy_and_replace(self, r): r.set("a", "foo1") r.set("b", "foo2") @@ -804,7 +859,7 @@ def test_copy_and_replace(self, r): assert r.copy("a", "b", replace=True) == 1 @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_copy_to_another_database(self, request): r0 = _get_client(redis.Redis, request, db=0) r1 = _get_client(redis.Redis, request, db=1) @@ -813,2268 +868,2477 @@ def test_copy_to_another_database(self, request): assert r1.get("b") == b"foo" def test_decr(self, r): - assert r.decr('a') == -1 - assert r['a'] == b'-1' - assert r.decr('a') == -2 - assert r['a'] == b'-2' - assert r.decr('a', amount=5) == -7 - assert r['a'] == b'-7' + assert r.decr("a") == -1 + assert r["a"] == b"-1" + assert r.decr("a") == -2 + assert r["a"] == b"-2" + assert r.decr("a", amount=5) == -7 + assert r["a"] == b"-7" def test_decrby(self, r): - assert r.decrby('a', amount=2) == -2 - assert r.decrby('a', amount=3) == -5 - assert r['a'] == b'-5' + assert r.decrby("a", amount=2) == -2 + assert r.decrby("a", amount=3) == -5 + assert r["a"] == b"-5" def test_delete(self, r): - assert r.delete('a') == 0 - r['a'] = 'foo' - assert r.delete('a') == 1 + assert r.delete("a") == 0 + r["a"] = "foo" + assert r.delete("a") == 1 def test_delete_with_multiple_keys(self, r): - r['a'] = 'foo' - r['b'] = 'bar' - assert r.delete('a', 'b') == 2 - assert r.get('a') is None - assert r.get('b') is None + r["a"] = "foo" + r["b"] = "bar" + assert r.delete("a", "b") == 2 + assert r.get("a") is None + assert r.get("b") is None def test_delitem(self, r): - r['a'] = 'foo' - del r['a'] - assert r.get('a') is None + r["a"] = "foo" + del r["a"] + assert r.get("a") is None - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_unlink(self, r): - assert r.unlink('a') == 0 - r['a'] = 'foo' - assert r.unlink('a') == 1 - assert r.get('a') is None + assert r.unlink("a") == 0 + r["a"] = "foo" + assert r.unlink("a") == 1 + assert r.get("a") is None - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_unlink_with_multiple_keys(self, r): - r['a'] = 'foo' - r['b'] = 'bar' - assert r.unlink('a', 'b') == 2 - assert r.get('a') is None - assert r.get('b') is None + r["a"] = "foo" + r["b"] = "bar" + assert r.unlink("a", "b") == 2 + assert r.get("a") is None + assert r.get("b") is None - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_dump_and_restore(self, r): - r['a'] = 'foo' - dumped = r.dump('a') - del r['a'] - r.restore('a', 0, dumped) - assert r['a'] == b'foo' + r["a"] = "foo" + dumped = r.dump("a") + del r["a"] + r.restore("a", 0, dumped) + assert r["a"] == b"foo" - @skip_if_server_version_lt('3.0.0') + @skip_if_server_version_lt("3.0.0") def test_dump_and_restore_and_replace(self, r): - r['a'] = 'bar' - dumped = r.dump('a') + r["a"] = "bar" + dumped = r.dump("a") with pytest.raises(redis.ResponseError): - r.restore('a', 0, dumped) + r.restore("a", 0, dumped) - r.restore('a', 0, dumped, replace=True) - assert r['a'] == b'bar' + r.restore("a", 0, dumped, replace=True) + assert r["a"] == b"bar" - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_dump_and_restore_absttl(self, r): - r['a'] = 'foo' - dumped = r.dump('a') - del r['a'] + r["a"] = "foo" + dumped = r.dump("a") + del r["a"] ttl = int( - (redis_server_time(r) + datetime.timedelta(minutes=1)).timestamp() - * 1000 + (redis_server_time(r) + datetime.timedelta(minutes=1)).timestamp() * 1000 ) - r.restore('a', ttl, dumped, absttl=True) - assert r['a'] == b'foo' - assert 0 < r.ttl('a') <= 61 + r.restore("a", ttl, dumped, absttl=True) + assert r["a"] == b"foo" + assert 0 < r.ttl("a") <= 61 def test_exists(self, r): - assert r.exists('a') == 0 - r['a'] = 'foo' - r['b'] = 'bar' - assert r.exists('a') == 1 - assert r.exists('a', 'b') == 2 + assert r.exists("a") == 0 + r["a"] = "foo" + r["b"] = "bar" + assert r.exists("a") == 1 + assert r.exists("a", "b") == 2 def test_exists_contains(self, r): - assert 'a' not in r - r['a'] = 'foo' - assert 'a' in r + assert "a" not in r + r["a"] = "foo" + assert "a" in r def test_expire(self, r): - assert r.expire('a', 10) is False - r['a'] = 'foo' - assert r.expire('a', 10) is True - assert 0 < r.ttl('a') <= 10 - assert r.persist('a') - assert r.ttl('a') == -1 + assert r.expire("a", 10) is False + r["a"] = "foo" + assert r.expire("a", 10) is True + assert 0 < r.ttl("a") <= 10 + assert r.persist("a") + assert r.ttl("a") == -1 def test_expireat_datetime(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - r['a'] = 'foo' - assert r.expireat('a', expire_at) is True - assert 0 < r.ttl('a') <= 61 + r["a"] = "foo" + assert r.expireat("a", expire_at) is True + assert 0 < r.ttl("a") <= 61 def test_expireat_no_key(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - assert r.expireat('a', expire_at) is False + assert r.expireat("a", expire_at) is False def test_expireat_unixtime(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - r['a'] = 'foo' + r["a"] = "foo" expire_at_seconds = int(time.mktime(expire_at.timetuple())) - assert r.expireat('a', expire_at_seconds) is True - assert 0 < r.ttl('a') <= 61 + assert r.expireat("a", expire_at_seconds) is True + assert 0 < r.ttl("a") <= 61 def test_get_and_set(self, r): # get and set can't be tested independently of each other - assert r.get('a') is None - byte_string = b'value' + assert r.get("a") is None + byte_string = b"value" integer = 5 - unicode_string = chr(3456) + 'abcd' + chr(3421) - assert r.set('byte_string', byte_string) - assert r.set('integer', 5) - assert r.set('unicode_string', unicode_string) - assert r.get('byte_string') == byte_string - assert r.get('integer') == str(integer).encode() - assert r.get('unicode_string').decode('utf-8') == unicode_string - - @skip_if_server_version_lt('6.2.0') + unicode_string = chr(3456) + "abcd" + chr(3421) + assert r.set("byte_string", byte_string) + assert r.set("integer", 5) + assert r.set("unicode_string", unicode_string) + assert r.get("byte_string") == byte_string + assert r.get("integer") == str(integer).encode() + assert r.get("unicode_string").decode("utf-8") == unicode_string + + @skip_if_server_version_lt("6.2.0") def test_getdel(self, r): - assert r.getdel('a') is None - r.set('a', 1) - assert r.getdel('a') == b'1' - assert r.getdel('a') is None + assert r.getdel("a") is None + r.set("a", 1) + assert r.getdel("a") == b"1" + assert r.getdel("a") is None - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_getex(self, r): - r.set('a', 1) - assert r.getex('a') == b'1' - assert r.ttl('a') == -1 - assert r.getex('a', ex=60) == b'1' - assert r.ttl('a') == 60 - assert r.getex('a', px=6000) == b'1' - assert r.ttl('a') == 6 + r.set("a", 1) + assert r.getex("a") == b"1" + assert r.ttl("a") == -1 + assert r.getex("a", ex=60) == b"1" + assert r.ttl("a") == 60 + assert r.getex("a", px=6000) == b"1" + assert r.ttl("a") == 6 expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - assert r.getex('a', pxat=expire_at) == b'1' - assert r.ttl('a') <= 61 - assert r.getex('a', persist=True) == b'1' - assert r.ttl('a') == -1 + assert r.getex("a", pxat=expire_at) == b"1" + assert r.ttl("a") <= 61 + assert r.getex("a", persist=True) == b"1" + assert r.ttl("a") == -1 def test_getitem_and_setitem(self, r): - r['a'] = 'bar' - assert r['a'] == b'bar' + r["a"] = "bar" + assert r["a"] == b"bar" def test_getitem_raises_keyerror_for_missing_key(self, r): with pytest.raises(KeyError): - r['a'] + r["a"] def test_getitem_does_not_raise_keyerror_for_empty_string(self, r): - r['a'] = b"" - assert r['a'] == b"" + r["a"] = b"" + assert r["a"] == b"" def test_get_set_bit(self, r): # no value - assert not r.getbit('a', 5) + assert not r.getbit("a", 5) # set bit 5 - assert not r.setbit('a', 5, True) - assert r.getbit('a', 5) + assert not r.setbit("a", 5, True) + assert r.getbit("a", 5) # unset bit 4 - assert not r.setbit('a', 4, False) - assert not r.getbit('a', 4) + assert not r.setbit("a", 4, False) + assert not r.getbit("a", 4) # set bit 4 - assert not r.setbit('a', 4, True) - assert r.getbit('a', 4) + assert not r.setbit("a", 4, True) + assert r.getbit("a", 4) # set bit 5 again - assert r.setbit('a', 5, True) - assert r.getbit('a', 5) + assert r.setbit("a", 5, True) + assert r.getbit("a", 5) def test_getrange(self, r): - r['a'] = 'foo' - assert r.getrange('a', 0, 0) == b'f' - assert r.getrange('a', 0, 2) == b'foo' - assert r.getrange('a', 3, 4) == b'' + r["a"] = "foo" + assert r.getrange("a", 0, 0) == b"f" + assert r.getrange("a", 0, 2) == b"foo" + assert r.getrange("a", 3, 4) == b"" def test_getset(self, r): - assert r.getset('a', 'foo') is None - assert r.getset('a', 'bar') == b'foo' - assert r.get('a') == b'bar' + assert r.getset("a", "foo") is None + assert r.getset("a", "bar") == b"foo" + assert r.get("a") == b"bar" def test_incr(self, r): - assert r.incr('a') == 1 - assert r['a'] == b'1' - assert r.incr('a') == 2 - assert r['a'] == b'2' - assert r.incr('a', amount=5) == 7 - assert r['a'] == b'7' + assert r.incr("a") == 1 + assert r["a"] == b"1" + assert r.incr("a") == 2 + assert r["a"] == b"2" + assert r.incr("a", amount=5) == 7 + assert r["a"] == b"7" def test_incrby(self, r): - assert r.incrby('a') == 1 - assert r.incrby('a', 4) == 5 - assert r['a'] == b'5' + assert r.incrby("a") == 1 + assert r.incrby("a", 4) == 5 + assert r["a"] == b"5" - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_incrbyfloat(self, r): - assert r.incrbyfloat('a') == 1.0 - assert r['a'] == b'1' - assert r.incrbyfloat('a', 1.1) == 2.1 - assert float(r['a']) == float(2.1) + assert r.incrbyfloat("a") == 1.0 + assert r["a"] == b"1" + assert r.incrbyfloat("a", 1.1) == 2.1 + assert float(r["a"]) == float(2.1) @pytest.mark.onlynoncluster def test_keys(self, r): assert r.keys() == [] - keys_with_underscores = {b'test_a', b'test_b'} - keys = keys_with_underscores.union({b'testc'}) + keys_with_underscores = {b"test_a", b"test_b"} + keys = keys_with_underscores.union({b"testc"}) for key in keys: r[key] = 1 - assert set(r.keys(pattern='test_*')) == keys_with_underscores - assert set(r.keys(pattern='test*')) == keys + assert set(r.keys(pattern="test_*")) == keys_with_underscores + assert set(r.keys(pattern="test*")) == keys @pytest.mark.onlynoncluster def test_mget(self, r): assert r.mget([]) == [] - assert r.mget(['a', 'b']) == [None, None] - r['a'] = '1' - r['b'] = '2' - r['c'] = '3' - assert r.mget('a', 'other', 'b', 'c') == [b'1', None, b'2', b'3'] + assert r.mget(["a", "b"]) == [None, None] + r["a"] = "1" + r["b"] = "2" + r["c"] = "3" + assert r.mget("a", "other", "b", "c") == [b"1", None, b"2", b"3"] @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_lmove(self, r): - r.rpush('a', 'one', 'two', 'three', 'four') - assert r.lmove('a', 'b') - assert r.lmove('a', 'b', 'right', 'left') + r.rpush("a", "one", "two", "three", "four") + assert r.lmove("a", "b") + assert r.lmove("a", "b", "right", "left") @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_blmove(self, r): - r.rpush('a', 'one', 'two', 'three', 'four') - assert r.blmove('a', 'b', 5) - assert r.blmove('a', 'b', 1, 'RIGHT', 'LEFT') + r.rpush("a", "one", "two", "three", "four") + assert r.blmove("a", "b", 5) + assert r.blmove("a", "b", 1, "RIGHT", "LEFT") @pytest.mark.onlynoncluster def test_mset(self, r): - d = {'a': b'1', 'b': b'2', 'c': b'3'} + d = {"a": b"1", "b": b"2", "c": b"3"} assert r.mset(d) for k, v in d.items(): assert r[k] == v @pytest.mark.onlynoncluster def test_msetnx(self, r): - d = {'a': b'1', 'b': b'2', 'c': b'3'} + d = {"a": b"1", "b": b"2", "c": b"3"} assert r.msetnx(d) - d2 = {'a': b'x', 'd': b'4'} + d2 = {"a": b"x", "d": b"4"} assert not r.msetnx(d2) for k, v in d.items(): assert r[k] == v - assert r.get('d') is None + assert r.get("d") is None - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_pexpire(self, r): - assert r.pexpire('a', 60000) is False - r['a'] = 'foo' - assert r.pexpire('a', 60000) is True - assert 0 < r.pttl('a') <= 60000 - assert r.persist('a') - assert r.pttl('a') == -1 - - @skip_if_server_version_lt('2.6.0') + assert r.pexpire("a", 60000) is False + r["a"] = "foo" + assert r.pexpire("a", 60000) is True + assert 0 < r.pttl("a") <= 60000 + assert r.persist("a") + assert r.pttl("a") == -1 + + @skip_if_server_version_lt("2.6.0") def test_pexpireat_datetime(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - r['a'] = 'foo' - assert r.pexpireat('a', expire_at) is True - assert 0 < r.pttl('a') <= 61000 + r["a"] = "foo" + assert r.pexpireat("a", expire_at) is True + assert 0 < r.pttl("a") <= 61000 - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_pexpireat_no_key(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - assert r.pexpireat('a', expire_at) is False + assert r.pexpireat("a", expire_at) is False - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_pexpireat_unixtime(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) - r['a'] = 'foo' + r["a"] = "foo" expire_at_seconds = int(time.mktime(expire_at.timetuple())) * 1000 - assert r.pexpireat('a', expire_at_seconds) is True - assert 0 < r.pttl('a') <= 61000 + assert r.pexpireat("a", expire_at_seconds) is True + assert 0 < r.pttl("a") <= 61000 - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_psetex(self, r): - assert r.psetex('a', 1000, 'value') - assert r['a'] == b'value' - assert 0 < r.pttl('a') <= 1000 + assert r.psetex("a", 1000, "value") + assert r["a"] == b"value" + assert 0 < r.pttl("a") <= 1000 - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_psetex_timedelta(self, r): expire_at = datetime.timedelta(milliseconds=1000) - assert r.psetex('a', expire_at, 'value') - assert r['a'] == b'value' - assert 0 < r.pttl('a') <= 1000 + assert r.psetex("a", expire_at, "value") + assert r["a"] == b"value" + assert 0 < r.pttl("a") <= 1000 - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_pttl(self, r): - assert r.pexpire('a', 10000) is False - r['a'] = '1' - assert r.pexpire('a', 10000) is True - assert 0 < r.pttl('a') <= 10000 - assert r.persist('a') - assert r.pttl('a') == -1 - - @skip_if_server_version_lt('2.8.0') + assert r.pexpire("a", 10000) is False + r["a"] = "1" + assert r.pexpire("a", 10000) is True + assert 0 < r.pttl("a") <= 10000 + assert r.persist("a") + assert r.pttl("a") == -1 + + @skip_if_server_version_lt("2.8.0") def test_pttl_no_key(self, r): "PTTL on servers 2.8 and after return -2 when the key doesn't exist" - assert r.pttl('a') == -2 + assert r.pttl("a") == -2 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_hrandfield(self, r): - assert r.hrandfield('key') is None - r.hset('key', mapping={'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5}) - assert r.hrandfield('key') is not None - assert len(r.hrandfield('key', 2)) == 2 + assert r.hrandfield("key") is None + r.hset("key", mapping={"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}) + assert r.hrandfield("key") is not None + assert len(r.hrandfield("key", 2)) == 2 # with values - assert len(r.hrandfield('key', 2, True)) == 4 + assert len(r.hrandfield("key", 2, True)) == 4 # without duplications - assert len(r.hrandfield('key', 10)) == 5 + assert len(r.hrandfield("key", 10)) == 5 # with duplications - assert len(r.hrandfield('key', -10)) == 10 + assert len(r.hrandfield("key", -10)) == 10 @pytest.mark.onlynoncluster def test_randomkey(self, r): assert r.randomkey() is None - for key in ('a', 'b', 'c'): + for key in ("a", "b", "c"): r[key] = 1 - assert r.randomkey() in (b'a', b'b', b'c') + assert r.randomkey() in (b"a", b"b", b"c") @pytest.mark.onlynoncluster def test_rename(self, r): - r['a'] = '1' - assert r.rename('a', 'b') - assert r.get('a') is None - assert r['b'] == b'1' + r["a"] = "1" + assert r.rename("a", "b") + assert r.get("a") is None + assert r["b"] == b"1" @pytest.mark.onlynoncluster def test_renamenx(self, r): - r['a'] = '1' - r['b'] = '2' - assert not r.renamenx('a', 'b') - assert r['a'] == b'1' - assert r['b'] == b'2' + r["a"] = "1" + r["b"] = "2" + assert not r.renamenx("a", "b") + assert r["a"] == b"1" + assert r["b"] == b"2" - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_set_nx(self, r): - assert r.set('a', '1', nx=True) - assert not r.set('a', '2', nx=True) - assert r['a'] == b'1' + assert r.set("a", "1", nx=True) + assert not r.set("a", "2", nx=True) + assert r["a"] == b"1" - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_set_xx(self, r): - assert not r.set('a', '1', xx=True) - assert r.get('a') is None - r['a'] = 'bar' - assert r.set('a', '2', xx=True) - assert r.get('a') == b'2' + assert not r.set("a", "1", xx=True) + assert r.get("a") is None + r["a"] = "bar" + assert r.set("a", "2", xx=True) + assert r.get("a") == b"2" - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_set_px(self, r): - assert r.set('a', '1', px=10000) - assert r['a'] == b'1' - assert 0 < r.pttl('a') <= 10000 - assert 0 < r.ttl('a') <= 10 + assert r.set("a", "1", px=10000) + assert r["a"] == b"1" + assert 0 < r.pttl("a") <= 10000 + assert 0 < r.ttl("a") <= 10 with pytest.raises(exceptions.DataError): - assert r.set('a', '1', px=10.0) + assert r.set("a", "1", px=10.0) - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_set_px_timedelta(self, r): expire_at = datetime.timedelta(milliseconds=1000) - assert r.set('a', '1', px=expire_at) - assert 0 < r.pttl('a') <= 1000 - assert 0 < r.ttl('a') <= 1 + assert r.set("a", "1", px=expire_at) + assert 0 < r.pttl("a") <= 1000 + assert 0 < r.ttl("a") <= 1 - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_set_ex(self, r): - assert r.set('a', '1', ex=10) - assert 0 < r.ttl('a') <= 10 + assert r.set("a", "1", ex=10) + assert 0 < r.ttl("a") <= 10 with pytest.raises(exceptions.DataError): - assert r.set('a', '1', ex=10.0) + assert r.set("a", "1", ex=10.0) - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_set_ex_timedelta(self, r): expire_at = datetime.timedelta(seconds=60) - assert r.set('a', '1', ex=expire_at) - assert 0 < r.ttl('a') <= 60 + assert r.set("a", "1", ex=expire_at) + assert 0 < r.ttl("a") <= 60 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_set_exat_timedelta(self, r): expire_at = redis_server_time(r) + datetime.timedelta(seconds=10) - assert r.set('a', '1', exat=expire_at) - assert 0 < r.ttl('a') <= 10 + assert r.set("a", "1", exat=expire_at) + assert 0 < r.ttl("a") <= 10 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_set_pxat_timedelta(self, r): expire_at = redis_server_time(r) + datetime.timedelta(seconds=50) - assert r.set('a', '1', pxat=expire_at) - assert 0 < r.ttl('a') <= 100 + assert r.set("a", "1", pxat=expire_at) + assert 0 < r.ttl("a") <= 100 - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_set_multipleoptions(self, r): - r['a'] = 'val' - assert r.set('a', '1', xx=True, px=10000) - assert 0 < r.ttl('a') <= 10 + r["a"] = "val" + assert r.set("a", "1", xx=True, px=10000) + assert 0 < r.ttl("a") <= 10 @skip_if_server_version_lt("6.0.0") def test_set_keepttl(self, r): - r['a'] = 'val' - assert r.set('a', '1', xx=True, px=10000) - assert 0 < r.ttl('a') <= 10 - r.set('a', '2', keepttl=True) - assert r.get('a') == b'2' - assert 0 < r.ttl('a') <= 10 - - @skip_if_server_version_lt('6.2.0') + r["a"] = "val" + assert r.set("a", "1", xx=True, px=10000) + assert 0 < r.ttl("a") <= 10 + r.set("a", "2", keepttl=True) + assert r.get("a") == b"2" + assert 0 < r.ttl("a") <= 10 + + @skip_if_server_version_lt("6.2.0") def test_set_get(self, r): - assert r.set('a', 'True', get=True) is None - assert r.set('a', 'True', get=True) == b'True' - assert r.set('a', 'foo') is True - assert r.set('a', 'bar', get=True) == b'foo' - assert r.get('a') == b'bar' + assert r.set("a", "True", get=True) is None + assert r.set("a", "True", get=True) == b"True" + assert r.set("a", "foo") is True + assert r.set("a", "bar", get=True) == b"foo" + assert r.get("a") == b"bar" def test_setex(self, r): - assert r.setex('a', 60, '1') - assert r['a'] == b'1' - assert 0 < r.ttl('a') <= 60 + assert r.setex("a", 60, "1") + assert r["a"] == b"1" + assert 0 < r.ttl("a") <= 60 def test_setnx(self, r): - assert r.setnx('a', '1') - assert r['a'] == b'1' - assert not r.setnx('a', '2') - assert r['a'] == b'1' + assert r.setnx("a", "1") + assert r["a"] == b"1" + assert not r.setnx("a", "2") + assert r["a"] == b"1" def test_setrange(self, r): - assert r.setrange('a', 5, 'foo') == 8 - assert r['a'] == b'\0\0\0\0\0foo' - r['a'] = 'abcdefghijh' - assert r.setrange('a', 6, '12345') == 11 - assert r['a'] == b'abcdef12345' + assert r.setrange("a", 5, "foo") == 8 + assert r["a"] == b"\0\0\0\0\0foo" + r["a"] = "abcdefghijh" + assert r.setrange("a", 6, "12345") == 11 + assert r["a"] == b"abcdef12345" - @skip_if_server_version_lt('6.0.0') + @skip_if_server_version_lt("6.0.0") def test_stralgo_lcs(self, r): - key1 = '{foo}key1' - key2 = '{foo}key2' - value1 = 'ohmytext' - value2 = 'mynewtext' - res = 'mytext' + key1 = "{foo}key1" + key2 = "{foo}key2" + value1 = "ohmytext" + value2 = "mynewtext" + res = "mytext" if skip_if_redis_enterprise(None).args[0] is True: with pytest.raises(redis.exceptions.ResponseError): - assert r.stralgo('LCS', value1, value2) == res + assert r.stralgo("LCS", value1, value2) == res return # test LCS of strings - assert r.stralgo('LCS', value1, value2) == res + assert r.stralgo("LCS", value1, value2) == res # test using keys r.mset({key1: value1, key2: value2}) - assert r.stralgo('LCS', key1, key2, specific_argument="keys") == res + assert r.stralgo("LCS", key1, key2, specific_argument="keys") == res # test other labels - assert r.stralgo('LCS', value1, value2, len=True) == len(res) - assert r.stralgo('LCS', value1, value2, idx=True) == \ - { - 'len': len(res), - 'matches': [[(4, 7), (5, 8)], [(2, 3), (0, 1)]] - } - assert r.stralgo('LCS', value1, value2, - idx=True, withmatchlen=True) == \ - { - 'len': len(res), - 'matches': [[4, (4, 7), (5, 8)], [2, (2, 3), (0, 1)]] - } - assert r.stralgo('LCS', value1, value2, - idx=True, minmatchlen=4, withmatchlen=True) == \ - { - 'len': len(res), - 'matches': [[4, (4, 7), (5, 8)]] - } - - @skip_if_server_version_lt('6.0.0') + assert r.stralgo("LCS", value1, value2, len=True) == len(res) + assert r.stralgo("LCS", value1, value2, idx=True) == { + "len": len(res), + "matches": [[(4, 7), (5, 8)], [(2, 3), (0, 1)]], + } + assert r.stralgo("LCS", value1, value2, idx=True, withmatchlen=True) == { + "len": len(res), + "matches": [[4, (4, 7), (5, 8)], [2, (2, 3), (0, 1)]], + } + assert r.stralgo( + "LCS", value1, value2, idx=True, minmatchlen=4, withmatchlen=True + ) == {"len": len(res), "matches": [[4, (4, 7), (5, 8)]]} + + @skip_if_server_version_lt("6.0.0") def test_stralgo_negative(self, r): with pytest.raises(exceptions.DataError): - r.stralgo('ISSUB', 'value1', 'value2') + r.stralgo("ISSUB", "value1", "value2") with pytest.raises(exceptions.DataError): - r.stralgo('LCS', 'value1', 'value2', len=True, idx=True) + r.stralgo("LCS", "value1", "value2", len=True, idx=True) with pytest.raises(exceptions.DataError): - r.stralgo('LCS', 'value1', 'value2', specific_argument="INT") + r.stralgo("LCS", "value1", "value2", specific_argument="INT") with pytest.raises(ValueError): - r.stralgo('LCS', 'value1', 'value2', idx=True, minmatchlen="one") + r.stralgo("LCS", "value1", "value2", idx=True, minmatchlen="one") def test_strlen(self, r): - r['a'] = 'foo' - assert r.strlen('a') == 3 + r["a"] = "foo" + assert r.strlen("a") == 3 def test_substr(self, r): - r['a'] = '0123456789' + r["a"] = "0123456789" if skip_if_redis_enterprise(None).args[0] is True: with pytest.raises(redis.exceptions.ResponseError): - assert r.substr('a', 0) == b'0123456789' + assert r.substr("a", 0) == b"0123456789" return - assert r.substr('a', 0) == b'0123456789' - assert r.substr('a', 2) == b'23456789' - assert r.substr('a', 3, 5) == b'345' - assert r.substr('a', 3, -2) == b'345678' + assert r.substr("a", 0) == b"0123456789" + assert r.substr("a", 2) == b"23456789" + assert r.substr("a", 3, 5) == b"345" + assert r.substr("a", 3, -2) == b"345678" def test_ttl(self, r): - r['a'] = '1' - assert r.expire('a', 10) - assert 0 < r.ttl('a') <= 10 - assert r.persist('a') - assert r.ttl('a') == -1 + r["a"] = "1" + assert r.expire("a", 10) + assert 0 < r.ttl("a") <= 10 + assert r.persist("a") + assert r.ttl("a") == -1 - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_ttl_nokey(self, r): "TTL on servers 2.8 and after return -2 when the key doesn't exist" - assert r.ttl('a') == -2 + assert r.ttl("a") == -2 def test_type(self, r): - assert r.type('a') == b'none' - r['a'] = '1' - assert r.type('a') == b'string' - del r['a'] - r.lpush('a', '1') - assert r.type('a') == b'list' - del r['a'] - r.sadd('a', '1') - assert r.type('a') == b'set' - del r['a'] - r.zadd('a', {'1': 1}) - assert r.type('a') == b'zset' + assert r.type("a") == b"none" + r["a"] = "1" + assert r.type("a") == b"string" + del r["a"] + r.lpush("a", "1") + assert r.type("a") == b"list" + del r["a"] + r.sadd("a", "1") + assert r.type("a") == b"set" + del r["a"] + r.zadd("a", {"1": 1}) + assert r.type("a") == b"zset" # LIST COMMANDS @pytest.mark.onlynoncluster def test_blpop(self, r): - r.rpush('a', '1', '2') - r.rpush('b', '3', '4') - assert r.blpop(['b', 'a'], timeout=1) == (b'b', b'3') - assert r.blpop(['b', 'a'], timeout=1) == (b'b', b'4') - assert r.blpop(['b', 'a'], timeout=1) == (b'a', b'1') - assert r.blpop(['b', 'a'], timeout=1) == (b'a', b'2') - assert r.blpop(['b', 'a'], timeout=1) is None - r.rpush('c', '1') - assert r.blpop('c', timeout=1) == (b'c', b'1') + r.rpush("a", "1", "2") + r.rpush("b", "3", "4") + assert r.blpop(["b", "a"], timeout=1) == (b"b", b"3") + assert r.blpop(["b", "a"], timeout=1) == (b"b", b"4") + assert r.blpop(["b", "a"], timeout=1) == (b"a", b"1") + assert r.blpop(["b", "a"], timeout=1) == (b"a", b"2") + assert r.blpop(["b", "a"], timeout=1) is None + r.rpush("c", "1") + assert r.blpop("c", timeout=1) == (b"c", b"1") @pytest.mark.onlynoncluster def test_brpop(self, r): - r.rpush('a', '1', '2') - r.rpush('b', '3', '4') - assert r.brpop(['b', 'a'], timeout=1) == (b'b', b'4') - assert r.brpop(['b', 'a'], timeout=1) == (b'b', b'3') - assert r.brpop(['b', 'a'], timeout=1) == (b'a', b'2') - assert r.brpop(['b', 'a'], timeout=1) == (b'a', b'1') - assert r.brpop(['b', 'a'], timeout=1) is None - r.rpush('c', '1') - assert r.brpop('c', timeout=1) == (b'c', b'1') + r.rpush("a", "1", "2") + r.rpush("b", "3", "4") + assert r.brpop(["b", "a"], timeout=1) == (b"b", b"4") + assert r.brpop(["b", "a"], timeout=1) == (b"b", b"3") + assert r.brpop(["b", "a"], timeout=1) == (b"a", b"2") + assert r.brpop(["b", "a"], timeout=1) == (b"a", b"1") + assert r.brpop(["b", "a"], timeout=1) is None + r.rpush("c", "1") + assert r.brpop("c", timeout=1) == (b"c", b"1") @pytest.mark.onlynoncluster def test_brpoplpush(self, r): - r.rpush('a', '1', '2') - r.rpush('b', '3', '4') - assert r.brpoplpush('a', 'b') == b'2' - assert r.brpoplpush('a', 'b') == b'1' - assert r.brpoplpush('a', 'b', timeout=1) is None - assert r.lrange('a', 0, -1) == [] - assert r.lrange('b', 0, -1) == [b'1', b'2', b'3', b'4'] + r.rpush("a", "1", "2") + r.rpush("b", "3", "4") + assert r.brpoplpush("a", "b") == b"2" + assert r.brpoplpush("a", "b") == b"1" + assert r.brpoplpush("a", "b", timeout=1) is None + assert r.lrange("a", 0, -1) == [] + assert r.lrange("b", 0, -1) == [b"1", b"2", b"3", b"4"] @pytest.mark.onlynoncluster def test_brpoplpush_empty_string(self, r): - r.rpush('a', '') - assert r.brpoplpush('a', 'b') == b'' + r.rpush("a", "") + assert r.brpoplpush("a", "b") == b"" def test_lindex(self, r): - r.rpush('a', '1', '2', '3') - assert r.lindex('a', '0') == b'1' - assert r.lindex('a', '1') == b'2' - assert r.lindex('a', '2') == b'3' + r.rpush("a", "1", "2", "3") + assert r.lindex("a", "0") == b"1" + assert r.lindex("a", "1") == b"2" + assert r.lindex("a", "2") == b"3" def test_linsert(self, r): - r.rpush('a', '1', '2', '3') - assert r.linsert('a', 'after', '2', '2.5') == 4 - assert r.lrange('a', 0, -1) == [b'1', b'2', b'2.5', b'3'] - assert r.linsert('a', 'before', '2', '1.5') == 5 - assert r.lrange('a', 0, -1) == \ - [b'1', b'1.5', b'2', b'2.5', b'3'] + r.rpush("a", "1", "2", "3") + assert r.linsert("a", "after", "2", "2.5") == 4 + assert r.lrange("a", 0, -1) == [b"1", b"2", b"2.5", b"3"] + assert r.linsert("a", "before", "2", "1.5") == 5 + assert r.lrange("a", 0, -1) == [b"1", b"1.5", b"2", b"2.5", b"3"] def test_llen(self, r): - r.rpush('a', '1', '2', '3') - assert r.llen('a') == 3 + r.rpush("a", "1", "2", "3") + assert r.llen("a") == 3 def test_lpop(self, r): - r.rpush('a', '1', '2', '3') - assert r.lpop('a') == b'1' - assert r.lpop('a') == b'2' - assert r.lpop('a') == b'3' - assert r.lpop('a') is None + r.rpush("a", "1", "2", "3") + assert r.lpop("a") == b"1" + assert r.lpop("a") == b"2" + assert r.lpop("a") == b"3" + assert r.lpop("a") is None - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_lpop_count(self, r): - r.rpush('a', '1', '2', '3') - assert r.lpop('a', 2) == [b'1', b'2'] - assert r.lpop('a', 1) == [b'3'] - assert r.lpop('a') is None - assert r.lpop('a', 3) is None + r.rpush("a", "1", "2", "3") + assert r.lpop("a", 2) == [b"1", b"2"] + assert r.lpop("a", 1) == [b"3"] + assert r.lpop("a") is None + assert r.lpop("a", 3) is None def test_lpush(self, r): - assert r.lpush('a', '1') == 1 - assert r.lpush('a', '2') == 2 - assert r.lpush('a', '3', '4') == 4 - assert r.lrange('a', 0, -1) == [b'4', b'3', b'2', b'1'] + assert r.lpush("a", "1") == 1 + assert r.lpush("a", "2") == 2 + assert r.lpush("a", "3", "4") == 4 + assert r.lrange("a", 0, -1) == [b"4", b"3", b"2", b"1"] def test_lpushx(self, r): - assert r.lpushx('a', '1') == 0 - assert r.lrange('a', 0, -1) == [] - r.rpush('a', '1', '2', '3') - assert r.lpushx('a', '4') == 4 - assert r.lrange('a', 0, -1) == [b'4', b'1', b'2', b'3'] + assert r.lpushx("a", "1") == 0 + assert r.lrange("a", 0, -1) == [] + r.rpush("a", "1", "2", "3") + assert r.lpushx("a", "4") == 4 + assert r.lrange("a", 0, -1) == [b"4", b"1", b"2", b"3"] - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_lpushx_with_list(self, r): # now with a list - r.lpush('somekey', 'a') - r.lpush('somekey', 'b') - assert r.lpushx('somekey', 'foo', 'asdasd', 55, 'asdasdas') == 6 - res = r.lrange('somekey', 0, -1) - assert res == [b'asdasdas', b'55', b'asdasd', b'foo', b'b', b'a'] + r.lpush("somekey", "a") + r.lpush("somekey", "b") + assert r.lpushx("somekey", "foo", "asdasd", 55, "asdasdas") == 6 + res = r.lrange("somekey", 0, -1) + assert res == [b"asdasdas", b"55", b"asdasd", b"foo", b"b", b"a"] def test_lrange(self, r): - r.rpush('a', '1', '2', '3', '4', '5') - assert r.lrange('a', 0, 2) == [b'1', b'2', b'3'] - assert r.lrange('a', 2, 10) == [b'3', b'4', b'5'] - assert r.lrange('a', 0, -1) == [b'1', b'2', b'3', b'4', b'5'] + r.rpush("a", "1", "2", "3", "4", "5") + assert r.lrange("a", 0, 2) == [b"1", b"2", b"3"] + assert r.lrange("a", 2, 10) == [b"3", b"4", b"5"] + assert r.lrange("a", 0, -1) == [b"1", b"2", b"3", b"4", b"5"] def test_lrem(self, r): - r.rpush('a', 'Z', 'b', 'Z', 'Z', 'c', 'Z', 'Z') + r.rpush("a", "Z", "b", "Z", "Z", "c", "Z", "Z") # remove the first 'Z' item - assert r.lrem('a', 1, 'Z') == 1 - assert r.lrange('a', 0, -1) == [b'b', b'Z', b'Z', b'c', b'Z', b'Z'] + assert r.lrem("a", 1, "Z") == 1 + assert r.lrange("a", 0, -1) == [b"b", b"Z", b"Z", b"c", b"Z", b"Z"] # remove the last 2 'Z' items - assert r.lrem('a', -2, 'Z') == 2 - assert r.lrange('a', 0, -1) == [b'b', b'Z', b'Z', b'c'] + assert r.lrem("a", -2, "Z") == 2 + assert r.lrange("a", 0, -1) == [b"b", b"Z", b"Z", b"c"] # remove all 'Z' items - assert r.lrem('a', 0, 'Z') == 2 - assert r.lrange('a', 0, -1) == [b'b', b'c'] + assert r.lrem("a", 0, "Z") == 2 + assert r.lrange("a", 0, -1) == [b"b", b"c"] def test_lset(self, r): - r.rpush('a', '1', '2', '3') - assert r.lrange('a', 0, -1) == [b'1', b'2', b'3'] - assert r.lset('a', 1, '4') - assert r.lrange('a', 0, 2) == [b'1', b'4', b'3'] + r.rpush("a", "1", "2", "3") + assert r.lrange("a", 0, -1) == [b"1", b"2", b"3"] + assert r.lset("a", 1, "4") + assert r.lrange("a", 0, 2) == [b"1", b"4", b"3"] def test_ltrim(self, r): - r.rpush('a', '1', '2', '3') - assert r.ltrim('a', 0, 1) - assert r.lrange('a', 0, -1) == [b'1', b'2'] + r.rpush("a", "1", "2", "3") + assert r.ltrim("a", 0, 1) + assert r.lrange("a", 0, -1) == [b"1", b"2"] def test_rpop(self, r): - r.rpush('a', '1', '2', '3') - assert r.rpop('a') == b'3' - assert r.rpop('a') == b'2' - assert r.rpop('a') == b'1' - assert r.rpop('a') is None + r.rpush("a", "1", "2", "3") + assert r.rpop("a") == b"3" + assert r.rpop("a") == b"2" + assert r.rpop("a") == b"1" + assert r.rpop("a") is None - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_rpop_count(self, r): - r.rpush('a', '1', '2', '3') - assert r.rpop('a', 2) == [b'3', b'2'] - assert r.rpop('a', 1) == [b'1'] - assert r.rpop('a') is None - assert r.rpop('a', 3) is None + r.rpush("a", "1", "2", "3") + assert r.rpop("a", 2) == [b"3", b"2"] + assert r.rpop("a", 1) == [b"1"] + assert r.rpop("a") is None + assert r.rpop("a", 3) is None @pytest.mark.onlynoncluster def test_rpoplpush(self, r): - r.rpush('a', 'a1', 'a2', 'a3') - r.rpush('b', 'b1', 'b2', 'b3') - assert r.rpoplpush('a', 'b') == b'a3' - assert r.lrange('a', 0, -1) == [b'a1', b'a2'] - assert r.lrange('b', 0, -1) == [b'a3', b'b1', b'b2', b'b3'] + r.rpush("a", "a1", "a2", "a3") + r.rpush("b", "b1", "b2", "b3") + assert r.rpoplpush("a", "b") == b"a3" + assert r.lrange("a", 0, -1) == [b"a1", b"a2"] + assert r.lrange("b", 0, -1) == [b"a3", b"b1", b"b2", b"b3"] def test_rpush(self, r): - assert r.rpush('a', '1') == 1 - assert r.rpush('a', '2') == 2 - assert r.rpush('a', '3', '4') == 4 - assert r.lrange('a', 0, -1) == [b'1', b'2', b'3', b'4'] + assert r.rpush("a", "1") == 1 + assert r.rpush("a", "2") == 2 + assert r.rpush("a", "3", "4") == 4 + assert r.lrange("a", 0, -1) == [b"1", b"2", b"3", b"4"] - @skip_if_server_version_lt('6.0.6') + @skip_if_server_version_lt("6.0.6") def test_lpos(self, r): - assert r.rpush('a', 'a', 'b', 'c', '1', '2', '3', 'c', 'c') == 8 - assert r.lpos('a', 'a') == 0 - assert r.lpos('a', 'c') == 2 + assert r.rpush("a", "a", "b", "c", "1", "2", "3", "c", "c") == 8 + assert r.lpos("a", "a") == 0 + assert r.lpos("a", "c") == 2 - assert r.lpos('a', 'c', rank=1) == 2 - assert r.lpos('a', 'c', rank=2) == 6 - assert r.lpos('a', 'c', rank=4) is None - assert r.lpos('a', 'c', rank=-1) == 7 - assert r.lpos('a', 'c', rank=-2) == 6 + assert r.lpos("a", "c", rank=1) == 2 + assert r.lpos("a", "c", rank=2) == 6 + assert r.lpos("a", "c", rank=4) is None + assert r.lpos("a", "c", rank=-1) == 7 + assert r.lpos("a", "c", rank=-2) == 6 - assert r.lpos('a', 'c', count=0) == [2, 6, 7] - assert r.lpos('a', 'c', count=1) == [2] - assert r.lpos('a', 'c', count=2) == [2, 6] - assert r.lpos('a', 'c', count=100) == [2, 6, 7] + assert r.lpos("a", "c", count=0) == [2, 6, 7] + assert r.lpos("a", "c", count=1) == [2] + assert r.lpos("a", "c", count=2) == [2, 6] + assert r.lpos("a", "c", count=100) == [2, 6, 7] - assert r.lpos('a', 'c', count=0, rank=2) == [6, 7] - assert r.lpos('a', 'c', count=2, rank=-1) == [7, 6] + assert r.lpos("a", "c", count=0, rank=2) == [6, 7] + assert r.lpos("a", "c", count=2, rank=-1) == [7, 6] - assert r.lpos('axxx', 'c', count=0, rank=2) == [] - assert r.lpos('axxx', 'c') is None + assert r.lpos("axxx", "c", count=0, rank=2) == [] + assert r.lpos("axxx", "c") is None - assert r.lpos('a', 'x', count=2) == [] - assert r.lpos('a', 'x') is None + assert r.lpos("a", "x", count=2) == [] + assert r.lpos("a", "x") is None - assert r.lpos('a', 'a', count=0, maxlen=1) == [0] - assert r.lpos('a', 'c', count=0, maxlen=1) == [] - assert r.lpos('a', 'c', count=0, maxlen=3) == [2] - assert r.lpos('a', 'c', count=0, maxlen=3, rank=-1) == [7, 6] - assert r.lpos('a', 'c', count=0, maxlen=7, rank=2) == [6] + assert r.lpos("a", "a", count=0, maxlen=1) == [0] + assert r.lpos("a", "c", count=0, maxlen=1) == [] + assert r.lpos("a", "c", count=0, maxlen=3) == [2] + assert r.lpos("a", "c", count=0, maxlen=3, rank=-1) == [7, 6] + assert r.lpos("a", "c", count=0, maxlen=7, rank=2) == [6] def test_rpushx(self, r): - assert r.rpushx('a', 'b') == 0 - assert r.lrange('a', 0, -1) == [] - r.rpush('a', '1', '2', '3') - assert r.rpushx('a', '4') == 4 - assert r.lrange('a', 0, -1) == [b'1', b'2', b'3', b'4'] + assert r.rpushx("a", "b") == 0 + assert r.lrange("a", 0, -1) == [] + r.rpush("a", "1", "2", "3") + assert r.rpushx("a", "4") == 4 + assert r.lrange("a", 0, -1) == [b"1", b"2", b"3", b"4"] # SCAN COMMANDS @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_scan(self, r): - r.set('a', 1) - r.set('b', 2) - r.set('c', 3) + r.set("a", 1) + r.set("b", 2) + r.set("c", 3) cursor, keys = r.scan() assert cursor == 0 - assert set(keys) == {b'a', b'b', b'c'} - _, keys = r.scan(match='a') - assert set(keys) == {b'a'} + assert set(keys) == {b"a", b"b", b"c"} + _, keys = r.scan(match="a") + assert set(keys) == {b"a"} @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_scan_type(self, r): - r.sadd('a-set', 1) - r.hset('a-hash', 'foo', 2) - r.lpush('a-list', 'aux', 3) - _, keys = r.scan(match='a*', _type='SET') - assert set(keys) == {b'a-set'} + r.sadd("a-set", 1) + r.hset("a-hash", "foo", 2) + r.lpush("a-list", "aux", 3) + _, keys = r.scan(match="a*", _type="SET") + assert set(keys) == {b"a-set"} @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_scan_iter(self, r): - r.set('a', 1) - r.set('b', 2) - r.set('c', 3) + r.set("a", 1) + r.set("b", 2) + r.set("c", 3) keys = list(r.scan_iter()) - assert set(keys) == {b'a', b'b', b'c'} - keys = list(r.scan_iter(match='a')) - assert set(keys) == {b'a'} + assert set(keys) == {b"a", b"b", b"c"} + keys = list(r.scan_iter(match="a")) + assert set(keys) == {b"a"} - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_sscan(self, r): - r.sadd('a', 1, 2, 3) - cursor, members = r.sscan('a') + r.sadd("a", 1, 2, 3) + cursor, members = r.sscan("a") assert cursor == 0 - assert set(members) == {b'1', b'2', b'3'} - _, members = r.sscan('a', match=b'1') - assert set(members) == {b'1'} + assert set(members) == {b"1", b"2", b"3"} + _, members = r.sscan("a", match=b"1") + assert set(members) == {b"1"} - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_sscan_iter(self, r): - r.sadd('a', 1, 2, 3) - members = list(r.sscan_iter('a')) - assert set(members) == {b'1', b'2', b'3'} - members = list(r.sscan_iter('a', match=b'1')) - assert set(members) == {b'1'} + r.sadd("a", 1, 2, 3) + members = list(r.sscan_iter("a")) + assert set(members) == {b"1", b"2", b"3"} + members = list(r.sscan_iter("a", match=b"1")) + assert set(members) == {b"1"} - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_hscan(self, r): - r.hset('a', mapping={'a': 1, 'b': 2, 'c': 3}) - cursor, dic = r.hscan('a') + r.hset("a", mapping={"a": 1, "b": 2, "c": 3}) + cursor, dic = r.hscan("a") assert cursor == 0 - assert dic == {b'a': b'1', b'b': b'2', b'c': b'3'} - _, dic = r.hscan('a', match='a') - assert dic == {b'a': b'1'} + assert dic == {b"a": b"1", b"b": b"2", b"c": b"3"} + _, dic = r.hscan("a", match="a") + assert dic == {b"a": b"1"} - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_hscan_iter(self, r): - r.hset('a', mapping={'a': 1, 'b': 2, 'c': 3}) - dic = dict(r.hscan_iter('a')) - assert dic == {b'a': b'1', b'b': b'2', b'c': b'3'} - dic = dict(r.hscan_iter('a', match='a')) - assert dic == {b'a': b'1'} + r.hset("a", mapping={"a": 1, "b": 2, "c": 3}) + dic = dict(r.hscan_iter("a")) + assert dic == {b"a": b"1", b"b": b"2", b"c": b"3"} + dic = dict(r.hscan_iter("a", match="a")) + assert dic == {b"a": b"1"} - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_zscan(self, r): - r.zadd('a', {'a': 1, 'b': 2, 'c': 3}) - cursor, pairs = r.zscan('a') + r.zadd("a", {"a": 1, "b": 2, "c": 3}) + cursor, pairs = r.zscan("a") assert cursor == 0 - assert set(pairs) == {(b'a', 1), (b'b', 2), (b'c', 3)} - _, pairs = r.zscan('a', match='a') - assert set(pairs) == {(b'a', 1)} + assert set(pairs) == {(b"a", 1), (b"b", 2), (b"c", 3)} + _, pairs = r.zscan("a", match="a") + assert set(pairs) == {(b"a", 1)} - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_zscan_iter(self, r): - r.zadd('a', {'a': 1, 'b': 2, 'c': 3}) - pairs = list(r.zscan_iter('a')) - assert set(pairs) == {(b'a', 1), (b'b', 2), (b'c', 3)} - pairs = list(r.zscan_iter('a', match='a')) - assert set(pairs) == {(b'a', 1)} + r.zadd("a", {"a": 1, "b": 2, "c": 3}) + pairs = list(r.zscan_iter("a")) + assert set(pairs) == {(b"a", 1), (b"b", 2), (b"c", 3)} + pairs = list(r.zscan_iter("a", match="a")) + assert set(pairs) == {(b"a", 1)} # SET COMMANDS def test_sadd(self, r): - members = {b'1', b'2', b'3'} - r.sadd('a', *members) - assert r.smembers('a') == members + members = {b"1", b"2", b"3"} + r.sadd("a", *members) + assert r.smembers("a") == members def test_scard(self, r): - r.sadd('a', '1', '2', '3') - assert r.scard('a') == 3 + r.sadd("a", "1", "2", "3") + assert r.scard("a") == 3 @pytest.mark.onlynoncluster def test_sdiff(self, r): - r.sadd('a', '1', '2', '3') - assert r.sdiff('a', 'b') == {b'1', b'2', b'3'} - r.sadd('b', '2', '3') - assert r.sdiff('a', 'b') == {b'1'} + r.sadd("a", "1", "2", "3") + assert r.sdiff("a", "b") == {b"1", b"2", b"3"} + r.sadd("b", "2", "3") + assert r.sdiff("a", "b") == {b"1"} @pytest.mark.onlynoncluster def test_sdiffstore(self, r): - r.sadd('a', '1', '2', '3') - assert r.sdiffstore('c', 'a', 'b') == 3 - assert r.smembers('c') == {b'1', b'2', b'3'} - r.sadd('b', '2', '3') - assert r.sdiffstore('c', 'a', 'b') == 1 - assert r.smembers('c') == {b'1'} + r.sadd("a", "1", "2", "3") + assert r.sdiffstore("c", "a", "b") == 3 + assert r.smembers("c") == {b"1", b"2", b"3"} + r.sadd("b", "2", "3") + assert r.sdiffstore("c", "a", "b") == 1 + assert r.smembers("c") == {b"1"} @pytest.mark.onlynoncluster def test_sinter(self, r): - r.sadd('a', '1', '2', '3') - assert r.sinter('a', 'b') == set() - r.sadd('b', '2', '3') - assert r.sinter('a', 'b') == {b'2', b'3'} + r.sadd("a", "1", "2", "3") + assert r.sinter("a", "b") == set() + r.sadd("b", "2", "3") + assert r.sinter("a", "b") == {b"2", b"3"} @pytest.mark.onlynoncluster def test_sinterstore(self, r): - r.sadd('a', '1', '2', '3') - assert r.sinterstore('c', 'a', 'b') == 0 - assert r.smembers('c') == set() - r.sadd('b', '2', '3') - assert r.sinterstore('c', 'a', 'b') == 2 - assert r.smembers('c') == {b'2', b'3'} + r.sadd("a", "1", "2", "3") + assert r.sinterstore("c", "a", "b") == 0 + assert r.smembers("c") == set() + r.sadd("b", "2", "3") + assert r.sinterstore("c", "a", "b") == 2 + assert r.smembers("c") == {b"2", b"3"} def test_sismember(self, r): - r.sadd('a', '1', '2', '3') - assert r.sismember('a', '1') - assert r.sismember('a', '2') - assert r.sismember('a', '3') - assert not r.sismember('a', '4') + r.sadd("a", "1", "2", "3") + assert r.sismember("a", "1") + assert r.sismember("a", "2") + assert r.sismember("a", "3") + assert not r.sismember("a", "4") def test_smembers(self, r): - r.sadd('a', '1', '2', '3') - assert r.smembers('a') == {b'1', b'2', b'3'} + r.sadd("a", "1", "2", "3") + assert r.smembers("a") == {b"1", b"2", b"3"} - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_smismember(self, r): - r.sadd('a', '1', '2', '3') + r.sadd("a", "1", "2", "3") result_list = [True, False, True, True] - assert r.smismember('a', '1', '4', '2', '3') == result_list - assert r.smismember('a', ['1', '4', '2', '3']) == result_list + assert r.smismember("a", "1", "4", "2", "3") == result_list + assert r.smismember("a", ["1", "4", "2", "3"]) == result_list @pytest.mark.onlynoncluster def test_smove(self, r): - r.sadd('a', 'a1', 'a2') - r.sadd('b', 'b1', 'b2') - assert r.smove('a', 'b', 'a1') - assert r.smembers('a') == {b'a2'} - assert r.smembers('b') == {b'b1', b'b2', b'a1'} + r.sadd("a", "a1", "a2") + r.sadd("b", "b1", "b2") + assert r.smove("a", "b", "a1") + assert r.smembers("a") == {b"a2"} + assert r.smembers("b") == {b"b1", b"b2", b"a1"} def test_spop(self, r): - s = [b'1', b'2', b'3'] - r.sadd('a', *s) - value = r.spop('a') + s = [b"1", b"2", b"3"] + r.sadd("a", *s) + value = r.spop("a") assert value in s - assert r.smembers('a') == set(s) - {value} + assert r.smembers("a") == set(s) - {value} - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_spop_multi_value(self, r): - s = [b'1', b'2', b'3'] - r.sadd('a', *s) - values = r.spop('a', 2) + s = [b"1", b"2", b"3"] + r.sadd("a", *s) + values = r.spop("a", 2) assert len(values) == 2 for value in values: assert value in s - assert r.spop('a', 1) == list(set(s) - set(values)) + assert r.spop("a", 1) == list(set(s) - set(values)) def test_srandmember(self, r): - s = [b'1', b'2', b'3'] - r.sadd('a', *s) - assert r.srandmember('a') in s + s = [b"1", b"2", b"3"] + r.sadd("a", *s) + assert r.srandmember("a") in s - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_srandmember_multi_value(self, r): - s = [b'1', b'2', b'3'] - r.sadd('a', *s) - randoms = r.srandmember('a', number=2) + s = [b"1", b"2", b"3"] + r.sadd("a", *s) + randoms = r.srandmember("a", number=2) assert len(randoms) == 2 assert set(randoms).intersection(s) == set(randoms) def test_srem(self, r): - r.sadd('a', '1', '2', '3', '4') - assert r.srem('a', '5') == 0 - assert r.srem('a', '2', '4') == 2 - assert r.smembers('a') == {b'1', b'3'} + r.sadd("a", "1", "2", "3", "4") + assert r.srem("a", "5") == 0 + assert r.srem("a", "2", "4") == 2 + assert r.smembers("a") == {b"1", b"3"} @pytest.mark.onlynoncluster def test_sunion(self, r): - r.sadd('a', '1', '2') - r.sadd('b', '2', '3') - assert r.sunion('a', 'b') == {b'1', b'2', b'3'} + r.sadd("a", "1", "2") + r.sadd("b", "2", "3") + assert r.sunion("a", "b") == {b"1", b"2", b"3"} @pytest.mark.onlynoncluster def test_sunionstore(self, r): - r.sadd('a', '1', '2') - r.sadd('b', '2', '3') - assert r.sunionstore('c', 'a', 'b') == 3 - assert r.smembers('c') == {b'1', b'2', b'3'} + r.sadd("a", "1", "2") + r.sadd("b", "2", "3") + assert r.sunionstore("c", "a", "b") == 3 + assert r.smembers("c") == {b"1", b"2", b"3"} - @skip_if_server_version_lt('1.0.0') + @skip_if_server_version_lt("1.0.0") def test_debug_segfault(self, r): with pytest.raises(NotImplementedError): r.debug_segfault() @pytest.mark.onlynoncluster - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_script_debug(self, r): with pytest.raises(NotImplementedError): r.script_debug() # SORTED SET COMMANDS def test_zadd(self, r): - mapping = {'a1': 1.0, 'a2': 2.0, 'a3': 3.0} - r.zadd('a', mapping) - assert r.zrange('a', 0, -1, withscores=True) == \ - [(b'a1', 1.0), (b'a2', 2.0), (b'a3', 3.0)] + mapping = {"a1": 1.0, "a2": 2.0, "a3": 3.0} + r.zadd("a", mapping) + assert r.zrange("a", 0, -1, withscores=True) == [ + (b"a1", 1.0), + (b"a2", 2.0), + (b"a3", 3.0), + ] # error cases with pytest.raises(exceptions.DataError): - r.zadd('a', {}) + r.zadd("a", {}) # cannot use both nx and xx options with pytest.raises(exceptions.DataError): - r.zadd('a', mapping, nx=True, xx=True) + r.zadd("a", mapping, nx=True, xx=True) # cannot use the incr options with more than one value with pytest.raises(exceptions.DataError): - r.zadd('a', mapping, incr=True) + r.zadd("a", mapping, incr=True) def test_zadd_nx(self, r): - assert r.zadd('a', {'a1': 1}) == 1 - assert r.zadd('a', {'a1': 99, 'a2': 2}, nx=True) == 1 - assert r.zrange('a', 0, -1, withscores=True) == \ - [(b'a1', 1.0), (b'a2', 2.0)] + assert r.zadd("a", {"a1": 1}) == 1 + assert r.zadd("a", {"a1": 99, "a2": 2}, nx=True) == 1 + assert r.zrange("a", 0, -1, withscores=True) == [(b"a1", 1.0), (b"a2", 2.0)] def test_zadd_xx(self, r): - assert r.zadd('a', {'a1': 1}) == 1 - assert r.zadd('a', {'a1': 99, 'a2': 2}, xx=True) == 0 - assert r.zrange('a', 0, -1, withscores=True) == \ - [(b'a1', 99.0)] + assert r.zadd("a", {"a1": 1}) == 1 + assert r.zadd("a", {"a1": 99, "a2": 2}, xx=True) == 0 + assert r.zrange("a", 0, -1, withscores=True) == [(b"a1", 99.0)] def test_zadd_ch(self, r): - assert r.zadd('a', {'a1': 1}) == 1 - assert r.zadd('a', {'a1': 99, 'a2': 2}, ch=True) == 2 - assert r.zrange('a', 0, -1, withscores=True) == \ - [(b'a2', 2.0), (b'a1', 99.0)] + assert r.zadd("a", {"a1": 1}) == 1 + assert r.zadd("a", {"a1": 99, "a2": 2}, ch=True) == 2 + assert r.zrange("a", 0, -1, withscores=True) == [(b"a2", 2.0), (b"a1", 99.0)] def test_zadd_incr(self, r): - assert r.zadd('a', {'a1': 1}) == 1 - assert r.zadd('a', {'a1': 4.5}, incr=True) == 5.5 + assert r.zadd("a", {"a1": 1}) == 1 + assert r.zadd("a", {"a1": 4.5}, incr=True) == 5.5 def test_zadd_incr_with_xx(self, r): # this asks zadd to incr 'a1' only if it exists, but it clearly # doesn't. Redis returns a null value in this case and so should # redis-py - assert r.zadd('a', {'a1': 1}, xx=True, incr=True) is None + assert r.zadd("a", {"a1": 1}, xx=True, incr=True) is None - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_zadd_gt_lt(self, r): for i in range(1, 20): - r.zadd('a', {f'a{i}': i}) - assert r.zadd('a', {'a20': 5}, gt=3) == 1 + r.zadd("a", {f"a{i}": i}) + assert r.zadd("a", {"a20": 5}, gt=3) == 1 for i in range(1, 20): - r.zadd('a', {f'a{i}': i}) - assert r.zadd('a', {'a2': 5}, lt=1) == 0 + r.zadd("a", {f"a{i}": i}) + assert r.zadd("a", {"a2": 5}, lt=1) == 0 # cannot use both nx and xx options with pytest.raises(exceptions.DataError): - r.zadd('a', {'a15': 155}, nx=True, lt=True) - r.zadd('a', {'a15': 155}, nx=True, gt=True) - r.zadd('a', {'a15': 155}, lt=True, gt=True) + r.zadd("a", {"a15": 155}, nx=True, lt=True) + r.zadd("a", {"a15": 155}, nx=True, gt=True) + r.zadd("a", {"a15": 155}, lt=True, gt=True) def test_zcard(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zcard('a') == 3 + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zcard("a") == 3 def test_zcount(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zcount('a', '-inf', '+inf') == 3 - assert r.zcount('a', 1, 2) == 2 - assert r.zcount('a', '(' + str(1), 2) == 1 - assert r.zcount('a', 1, '(' + str(2)) == 1 - assert r.zcount('a', 10, 20) == 0 + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zcount("a", "-inf", "+inf") == 3 + assert r.zcount("a", 1, 2) == 2 + assert r.zcount("a", "(" + str(1), 2) == 1 + assert r.zcount("a", 1, "(" + str(2)) == 1 + assert r.zcount("a", 10, 20) == 0 @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_zdiff(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - r.zadd('b', {'a1': 1, 'a2': 2}) - assert r.zdiff(['a', 'b']) == [b'a3'] - assert r.zdiff(['a', 'b'], withscores=True) == [b'a3', b'3'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + r.zadd("b", {"a1": 1, "a2": 2}) + assert r.zdiff(["a", "b"]) == [b"a3"] + assert r.zdiff(["a", "b"], withscores=True) == [b"a3", b"3"] @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_zdiffstore(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - r.zadd('b', {'a1': 1, 'a2': 2}) - assert r.zdiffstore("out", ['a', 'b']) - assert r.zrange("out", 0, -1) == [b'a3'] - assert r.zrange("out", 0, -1, withscores=True) == [(b'a3', 3.0)] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + r.zadd("b", {"a1": 1, "a2": 2}) + assert r.zdiffstore("out", ["a", "b"]) + assert r.zrange("out", 0, -1) == [b"a3"] + assert r.zrange("out", 0, -1, withscores=True) == [(b"a3", 3.0)] def test_zincrby(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zincrby('a', 1, 'a2') == 3.0 - assert r.zincrby('a', 5, 'a3') == 8.0 - assert r.zscore('a', 'a2') == 3.0 - assert r.zscore('a', 'a3') == 8.0 + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zincrby("a", 1, "a2") == 3.0 + assert r.zincrby("a", 5, "a3") == 8.0 + assert r.zscore("a", "a2") == 3.0 + assert r.zscore("a", "a3") == 8.0 - @skip_if_server_version_lt('2.8.9') + @skip_if_server_version_lt("2.8.9") def test_zlexcount(self, r): - r.zadd('a', {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0}) - assert r.zlexcount('a', '-', '+') == 7 - assert r.zlexcount('a', '[b', '[f') == 5 + r.zadd("a", {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0, "f": 0, "g": 0}) + assert r.zlexcount("a", "-", "+") == 7 + assert r.zlexcount("a", "[b", "[f") == 5 @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_zinter(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 1}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinter(['a', 'b', 'c']) == [b'a3', b'a1'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 1}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zinter(["a", "b", "c"]) == [b"a3", b"a1"] # invalid aggregation with pytest.raises(exceptions.DataError): - r.zinter(['a', 'b', 'c'], aggregate='foo', withscores=True) + r.zinter(["a", "b", "c"], aggregate="foo", withscores=True) # aggregate with SUM - assert r.zinter(['a', 'b', 'c'], withscores=True) \ - == [(b'a3', 8), (b'a1', 9)] + assert r.zinter(["a", "b", "c"], withscores=True) == [(b"a3", 8), (b"a1", 9)] # aggregate with MAX - assert r.zinter(['a', 'b', 'c'], aggregate='MAX', withscores=True) \ - == [(b'a3', 5), (b'a1', 6)] + assert r.zinter(["a", "b", "c"], aggregate="MAX", withscores=True) == [ + (b"a3", 5), + (b"a1", 6), + ] # aggregate with MIN - assert r.zinter(['a', 'b', 'c'], aggregate='MIN', withscores=True) \ - == [(b'a1', 1), (b'a3', 1)] + assert r.zinter(["a", "b", "c"], aggregate="MIN", withscores=True) == [ + (b"a1", 1), + (b"a3", 1), + ] # with weights - assert r.zinter({'a': 1, 'b': 2, 'c': 3}, withscores=True) \ - == [(b'a3', 20), (b'a1', 23)] + assert r.zinter({"a": 1, "b": 2, "c": 3}, withscores=True) == [ + (b"a3", 20), + (b"a1", 23), + ] @pytest.mark.onlynoncluster def test_zinterstore_sum(self, r): - r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinterstore('d', ['a', 'b', 'c']) == 2 - assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a3', 8), (b'a1', 9)] + r.zadd("a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zinterstore("d", ["a", "b", "c"]) == 2 + assert r.zrange("d", 0, -1, withscores=True) == [(b"a3", 8), (b"a1", 9)] @pytest.mark.onlynoncluster def test_zinterstore_max(self, r): - r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinterstore('d', ['a', 'b', 'c'], aggregate='MAX') == 2 - assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a3', 5), (b'a1', 6)] + r.zadd("a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zinterstore("d", ["a", "b", "c"], aggregate="MAX") == 2 + assert r.zrange("d", 0, -1, withscores=True) == [(b"a3", 5), (b"a1", 6)] @pytest.mark.onlynoncluster def test_zinterstore_min(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - r.zadd('b', {'a1': 2, 'a2': 3, 'a3': 5}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinterstore('d', ['a', 'b', 'c'], aggregate='MIN') == 2 - assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a1', 1), (b'a3', 3)] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + r.zadd("b", {"a1": 2, "a2": 3, "a3": 5}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zinterstore("d", ["a", "b", "c"], aggregate="MIN") == 2 + assert r.zrange("d", 0, -1, withscores=True) == [(b"a1", 1), (b"a3", 3)] @pytest.mark.onlynoncluster def test_zinterstore_with_weight(self, r): - r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zinterstore('d', {'a': 1, 'b': 2, 'c': 3}) == 2 - assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a3', 20), (b'a1', 23)] - - @skip_if_server_version_lt('4.9.0') + r.zadd("a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zinterstore("d", {"a": 1, "b": 2, "c": 3}) == 2 + assert r.zrange("d", 0, -1, withscores=True) == [(b"a3", 20), (b"a1", 23)] + + @skip_if_server_version_lt("4.9.0") def test_zpopmax(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zpopmax('a') == [(b'a3', 3)] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zpopmax("a") == [(b"a3", 3)] # with count - assert r.zpopmax('a', count=2) == \ - [(b'a2', 2), (b'a1', 1)] + assert r.zpopmax("a", count=2) == [(b"a2", 2), (b"a1", 1)] - @skip_if_server_version_lt('4.9.0') + @skip_if_server_version_lt("4.9.0") def test_zpopmin(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zpopmin('a') == [(b'a1', 1)] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zpopmin("a") == [(b"a1", 1)] # with count - assert r.zpopmin('a', count=2) == \ - [(b'a2', 2), (b'a3', 3)] + assert r.zpopmin("a", count=2) == [(b"a2", 2), (b"a3", 3)] - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_zrandemember(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) - assert r.zrandmember('a') is not None - assert len(r.zrandmember('a', 2)) == 2 + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3, "a4": 4, "a5": 5}) + assert r.zrandmember("a") is not None + assert len(r.zrandmember("a", 2)) == 2 # with scores - assert len(r.zrandmember('a', 2, True)) == 4 + assert len(r.zrandmember("a", 2, True)) == 4 # without duplications - assert len(r.zrandmember('a', 10)) == 5 + assert len(r.zrandmember("a", 10)) == 5 # with duplications - assert len(r.zrandmember('a', -10)) == 10 + assert len(r.zrandmember("a", -10)) == 10 @pytest.mark.onlynoncluster - @skip_if_server_version_lt('4.9.0') + @skip_if_server_version_lt("4.9.0") def test_bzpopmax(self, r): - r.zadd('a', {'a1': 1, 'a2': 2}) - r.zadd('b', {'b1': 10, 'b2': 20}) - assert r.bzpopmax(['b', 'a'], timeout=1) == (b'b', b'b2', 20) - assert r.bzpopmax(['b', 'a'], timeout=1) == (b'b', b'b1', 10) - assert r.bzpopmax(['b', 'a'], timeout=1) == (b'a', b'a2', 2) - assert r.bzpopmax(['b', 'a'], timeout=1) == (b'a', b'a1', 1) - assert r.bzpopmax(['b', 'a'], timeout=1) is None - r.zadd('c', {'c1': 100}) - assert r.bzpopmax('c', timeout=1) == (b'c', b'c1', 100) - - @pytest.mark.onlynoncluster - @skip_if_server_version_lt('4.9.0') + r.zadd("a", {"a1": 1, "a2": 2}) + r.zadd("b", {"b1": 10, "b2": 20}) + assert r.bzpopmax(["b", "a"], timeout=1) == (b"b", b"b2", 20) + assert r.bzpopmax(["b", "a"], timeout=1) == (b"b", b"b1", 10) + assert r.bzpopmax(["b", "a"], timeout=1) == (b"a", b"a2", 2) + assert r.bzpopmax(["b", "a"], timeout=1) == (b"a", b"a1", 1) + assert r.bzpopmax(["b", "a"], timeout=1) is None + r.zadd("c", {"c1": 100}) + assert r.bzpopmax("c", timeout=1) == (b"c", b"c1", 100) + + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("4.9.0") def test_bzpopmin(self, r): - r.zadd('a', {'a1': 1, 'a2': 2}) - r.zadd('b', {'b1': 10, 'b2': 20}) - assert r.bzpopmin(['b', 'a'], timeout=1) == (b'b', b'b1', 10) - assert r.bzpopmin(['b', 'a'], timeout=1) == (b'b', b'b2', 20) - assert r.bzpopmin(['b', 'a'], timeout=1) == (b'a', b'a1', 1) - assert r.bzpopmin(['b', 'a'], timeout=1) == (b'a', b'a2', 2) - assert r.bzpopmin(['b', 'a'], timeout=1) is None - r.zadd('c', {'c1': 100}) - assert r.bzpopmin('c', timeout=1) == (b'c', b'c1', 100) + r.zadd("a", {"a1": 1, "a2": 2}) + r.zadd("b", {"b1": 10, "b2": 20}) + assert r.bzpopmin(["b", "a"], timeout=1) == (b"b", b"b1", 10) + assert r.bzpopmin(["b", "a"], timeout=1) == (b"b", b"b2", 20) + assert r.bzpopmin(["b", "a"], timeout=1) == (b"a", b"a1", 1) + assert r.bzpopmin(["b", "a"], timeout=1) == (b"a", b"a2", 2) + assert r.bzpopmin(["b", "a"], timeout=1) is None + r.zadd("c", {"c1": 100}) + assert r.bzpopmin("c", timeout=1) == (b"c", b"c1", 100) def test_zrange(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zrange('a', 0, 1) == [b'a1', b'a2'] - assert r.zrange('a', 1, 2) == [b'a2', b'a3'] - assert r.zrange('a', 0, 2) == [b'a1', b'a2', b'a3'] - assert r.zrange('a', 0, 2, desc=True) == [b'a3', b'a2', b'a1'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zrange("a", 0, 1) == [b"a1", b"a2"] + assert r.zrange("a", 1, 2) == [b"a2", b"a3"] + assert r.zrange("a", 0, 2) == [b"a1", b"a2", b"a3"] + assert r.zrange("a", 0, 2, desc=True) == [b"a3", b"a2", b"a1"] # withscores - assert r.zrange('a', 0, 1, withscores=True) == \ - [(b'a1', 1.0), (b'a2', 2.0)] - assert r.zrange('a', 1, 2, withscores=True) == \ - [(b'a2', 2.0), (b'a3', 3.0)] + assert r.zrange("a", 0, 1, withscores=True) == [(b"a1", 1.0), (b"a2", 2.0)] + assert r.zrange("a", 1, 2, withscores=True) == [(b"a2", 2.0), (b"a3", 3.0)] # custom score function - assert r.zrange('a', 0, 1, withscores=True, score_cast_func=int) == \ - [(b'a1', 1), (b'a2', 2)] + assert r.zrange("a", 0, 1, withscores=True, score_cast_func=int) == [ + (b"a1", 1), + (b"a2", 2), + ] def test_zrange_errors(self, r): with pytest.raises(exceptions.DataError): - r.zrange('a', 0, 1, byscore=True, bylex=True) + r.zrange("a", 0, 1, byscore=True, bylex=True) with pytest.raises(exceptions.DataError): - r.zrange('a', 0, 1, bylex=True, withscores=True) + r.zrange("a", 0, 1, bylex=True, withscores=True) with pytest.raises(exceptions.DataError): - r.zrange('a', 0, 1, byscore=True, withscores=True, offset=4) + r.zrange("a", 0, 1, byscore=True, withscores=True, offset=4) with pytest.raises(exceptions.DataError): - r.zrange('a', 0, 1, byscore=True, withscores=True, num=2) + r.zrange("a", 0, 1, byscore=True, withscores=True, num=2) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_zrange_params(self, r): # bylex - r.zadd('a', {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0}) - assert r.zrange('a', '[aaa', '(g', bylex=True) == \ - [b'b', b'c', b'd', b'e', b'f'] - assert r.zrange('a', '[f', '+', bylex=True) == [b'f', b'g'] - assert r.zrange('a', '+', '[f', desc=True, bylex=True) == [b'g', b'f'] - assert r.zrange('a', '-', '+', bylex=True, offset=3, num=2) == \ - [b'd', b'e'] - assert r.zrange('a', '+', '-', desc=True, bylex=True, - offset=3, num=2) == \ - [b'd', b'c'] + r.zadd("a", {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0, "f": 0, "g": 0}) + assert r.zrange("a", "[aaa", "(g", bylex=True) == [b"b", b"c", b"d", b"e", b"f"] + assert r.zrange("a", "[f", "+", bylex=True) == [b"f", b"g"] + assert r.zrange("a", "+", "[f", desc=True, bylex=True) == [b"g", b"f"] + assert r.zrange("a", "-", "+", bylex=True, offset=3, num=2) == [b"d", b"e"] + assert r.zrange("a", "+", "-", desc=True, bylex=True, offset=3, num=2) == [ + b"d", + b"c", + ] # byscore - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) - assert r.zrange('a', 2, 4, byscore=True, offset=1, num=2) == \ - [b'a3', b'a4'] - assert r.zrange('a', 4, 2, desc=True, byscore=True, - offset=1, num=2) == \ - [b'a3', b'a2'] - assert r.zrange('a', 2, 4, byscore=True, withscores=True) == \ - [(b'a2', 2.0), (b'a3', 3.0), (b'a4', 4.0)] - assert r.zrange('a', 4, 2, desc=True, byscore=True, - withscores=True, score_cast_func=int) == \ - [(b'a4', 4), (b'a3', 3), (b'a2', 2)] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3, "a4": 4, "a5": 5}) + assert r.zrange("a", 2, 4, byscore=True, offset=1, num=2) == [b"a3", b"a4"] + assert r.zrange("a", 4, 2, desc=True, byscore=True, offset=1, num=2) == [ + b"a3", + b"a2", + ] + assert r.zrange("a", 2, 4, byscore=True, withscores=True) == [ + (b"a2", 2.0), + (b"a3", 3.0), + (b"a4", 4.0), + ] + assert r.zrange( + "a", 4, 2, desc=True, byscore=True, withscores=True, score_cast_func=int + ) == [(b"a4", 4), (b"a3", 3), (b"a2", 2)] # rev - assert r.zrange('a', 0, 1, desc=True) == [b'a5', b'a4'] + assert r.zrange("a", 0, 1, desc=True) == [b"a5", b"a4"] @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_zrangestore(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zrangestore('b', 'a', 0, 1) - assert r.zrange('b', 0, -1) == [b'a1', b'a2'] - assert r.zrangestore('b', 'a', 1, 2) - assert r.zrange('b', 0, -1) == [b'a2', b'a3'] - assert r.zrange('b', 0, -1, withscores=True) == \ - [(b'a2', 2), (b'a3', 3)] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zrangestore("b", "a", 0, 1) + assert r.zrange("b", 0, -1) == [b"a1", b"a2"] + assert r.zrangestore("b", "a", 1, 2) + assert r.zrange("b", 0, -1) == [b"a2", b"a3"] + assert r.zrange("b", 0, -1, withscores=True) == [(b"a2", 2), (b"a3", 3)] # reversed order - assert r.zrangestore('b', 'a', 1, 2, desc=True) - assert r.zrange('b', 0, -1) == [b'a1', b'a2'] + assert r.zrangestore("b", "a", 1, 2, desc=True) + assert r.zrange("b", 0, -1) == [b"a1", b"a2"] # by score - assert r.zrangestore('b', 'a', 2, 1, byscore=True, - offset=0, num=1, desc=True) - assert r.zrange('b', 0, -1) == [b'a2'] + assert r.zrangestore("b", "a", 2, 1, byscore=True, offset=0, num=1, desc=True) + assert r.zrange("b", 0, -1) == [b"a2"] # by lex - assert r.zrangestore('b', 'a', '[a2', '(a3', bylex=True, - offset=0, num=1) - assert r.zrange('b', 0, -1) == [b'a2'] + assert r.zrangestore("b", "a", "[a2", "(a3", bylex=True, offset=0, num=1) + assert r.zrange("b", 0, -1) == [b"a2"] - @skip_if_server_version_lt('2.8.9') + @skip_if_server_version_lt("2.8.9") def test_zrangebylex(self, r): - r.zadd('a', {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0}) - assert r.zrangebylex('a', '-', '[c') == [b'a', b'b', b'c'] - assert r.zrangebylex('a', '-', '(c') == [b'a', b'b'] - assert r.zrangebylex('a', '[aaa', '(g') == \ - [b'b', b'c', b'd', b'e', b'f'] - assert r.zrangebylex('a', '[f', '+') == [b'f', b'g'] - assert r.zrangebylex('a', '-', '+', start=3, num=2) == [b'd', b'e'] - - @skip_if_server_version_lt('2.9.9') + r.zadd("a", {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0, "f": 0, "g": 0}) + assert r.zrangebylex("a", "-", "[c") == [b"a", b"b", b"c"] + assert r.zrangebylex("a", "-", "(c") == [b"a", b"b"] + assert r.zrangebylex("a", "[aaa", "(g") == [b"b", b"c", b"d", b"e", b"f"] + assert r.zrangebylex("a", "[f", "+") == [b"f", b"g"] + assert r.zrangebylex("a", "-", "+", start=3, num=2) == [b"d", b"e"] + + @skip_if_server_version_lt("2.9.9") def test_zrevrangebylex(self, r): - r.zadd('a', {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0}) - assert r.zrevrangebylex('a', '[c', '-') == [b'c', b'b', b'a'] - assert r.zrevrangebylex('a', '(c', '-') == [b'b', b'a'] - assert r.zrevrangebylex('a', '(g', '[aaa') == \ - [b'f', b'e', b'd', b'c', b'b'] - assert r.zrevrangebylex('a', '+', '[f') == [b'g', b'f'] - assert r.zrevrangebylex('a', '+', '-', start=3, num=2) == \ - [b'd', b'c'] + r.zadd("a", {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0, "f": 0, "g": 0}) + assert r.zrevrangebylex("a", "[c", "-") == [b"c", b"b", b"a"] + assert r.zrevrangebylex("a", "(c", "-") == [b"b", b"a"] + assert r.zrevrangebylex("a", "(g", "[aaa") == [b"f", b"e", b"d", b"c", b"b"] + assert r.zrevrangebylex("a", "+", "[f") == [b"g", b"f"] + assert r.zrevrangebylex("a", "+", "-", start=3, num=2) == [b"d", b"c"] def test_zrangebyscore(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) - assert r.zrangebyscore('a', 2, 4) == [b'a2', b'a3', b'a4'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3, "a4": 4, "a5": 5}) + assert r.zrangebyscore("a", 2, 4) == [b"a2", b"a3", b"a4"] # slicing with start/num - assert r.zrangebyscore('a', 2, 4, start=1, num=2) == \ - [b'a3', b'a4'] + assert r.zrangebyscore("a", 2, 4, start=1, num=2) == [b"a3", b"a4"] # withscores - assert r.zrangebyscore('a', 2, 4, withscores=True) == \ - [(b'a2', 2.0), (b'a3', 3.0), (b'a4', 4.0)] - assert r.zrangebyscore('a', 2, 4, withscores=True, - score_cast_func=int) == \ - [(b'a2', 2), (b'a3', 3), (b'a4', 4)] + assert r.zrangebyscore("a", 2, 4, withscores=True) == [ + (b"a2", 2.0), + (b"a3", 3.0), + (b"a4", 4.0), + ] + assert r.zrangebyscore("a", 2, 4, withscores=True, score_cast_func=int) == [ + (b"a2", 2), + (b"a3", 3), + (b"a4", 4), + ] def test_zrank(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) - assert r.zrank('a', 'a1') == 0 - assert r.zrank('a', 'a2') == 1 - assert r.zrank('a', 'a6') is None + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3, "a4": 4, "a5": 5}) + assert r.zrank("a", "a1") == 0 + assert r.zrank("a", "a2") == 1 + assert r.zrank("a", "a6") is None def test_zrem(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zrem('a', 'a2') == 1 - assert r.zrange('a', 0, -1) == [b'a1', b'a3'] - assert r.zrem('a', 'b') == 0 - assert r.zrange('a', 0, -1) == [b'a1', b'a3'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zrem("a", "a2") == 1 + assert r.zrange("a", 0, -1) == [b"a1", b"a3"] + assert r.zrem("a", "b") == 0 + assert r.zrange("a", 0, -1) == [b"a1", b"a3"] def test_zrem_multiple_keys(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zrem('a', 'a1', 'a2') == 2 - assert r.zrange('a', 0, 5) == [b'a3'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zrem("a", "a1", "a2") == 2 + assert r.zrange("a", 0, 5) == [b"a3"] - @skip_if_server_version_lt('2.8.9') + @skip_if_server_version_lt("2.8.9") def test_zremrangebylex(self, r): - r.zadd('a', {'a': 0, 'b': 0, 'c': 0, 'd': 0, 'e': 0, 'f': 0, 'g': 0}) - assert r.zremrangebylex('a', '-', '[c') == 3 - assert r.zrange('a', 0, -1) == [b'd', b'e', b'f', b'g'] - assert r.zremrangebylex('a', '[f', '+') == 2 - assert r.zrange('a', 0, -1) == [b'd', b'e'] - assert r.zremrangebylex('a', '[h', '+') == 0 - assert r.zrange('a', 0, -1) == [b'd', b'e'] + r.zadd("a", {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0, "f": 0, "g": 0}) + assert r.zremrangebylex("a", "-", "[c") == 3 + assert r.zrange("a", 0, -1) == [b"d", b"e", b"f", b"g"] + assert r.zremrangebylex("a", "[f", "+") == 2 + assert r.zrange("a", 0, -1) == [b"d", b"e"] + assert r.zremrangebylex("a", "[h", "+") == 0 + assert r.zrange("a", 0, -1) == [b"d", b"e"] def test_zremrangebyrank(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) - assert r.zremrangebyrank('a', 1, 3) == 3 - assert r.zrange('a', 0, 5) == [b'a1', b'a5'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3, "a4": 4, "a5": 5}) + assert r.zremrangebyrank("a", 1, 3) == 3 + assert r.zrange("a", 0, 5) == [b"a1", b"a5"] def test_zremrangebyscore(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) - assert r.zremrangebyscore('a', 2, 4) == 3 - assert r.zrange('a', 0, -1) == [b'a1', b'a5'] - assert r.zremrangebyscore('a', 2, 4) == 0 - assert r.zrange('a', 0, -1) == [b'a1', b'a5'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3, "a4": 4, "a5": 5}) + assert r.zremrangebyscore("a", 2, 4) == 3 + assert r.zrange("a", 0, -1) == [b"a1", b"a5"] + assert r.zremrangebyscore("a", 2, 4) == 0 + assert r.zrange("a", 0, -1) == [b"a1", b"a5"] def test_zrevrange(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zrevrange('a', 0, 1) == [b'a3', b'a2'] - assert r.zrevrange('a', 1, 2) == [b'a2', b'a1'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zrevrange("a", 0, 1) == [b"a3", b"a2"] + assert r.zrevrange("a", 1, 2) == [b"a2", b"a1"] # withscores - assert r.zrevrange('a', 0, 1, withscores=True) == \ - [(b'a3', 3.0), (b'a2', 2.0)] - assert r.zrevrange('a', 1, 2, withscores=True) == \ - [(b'a2', 2.0), (b'a1', 1.0)] + assert r.zrevrange("a", 0, 1, withscores=True) == [(b"a3", 3.0), (b"a2", 2.0)] + assert r.zrevrange("a", 1, 2, withscores=True) == [(b"a2", 2.0), (b"a1", 1.0)] # custom score function - assert r.zrevrange('a', 0, 1, withscores=True, - score_cast_func=int) == \ - [(b'a3', 3.0), (b'a2', 2.0)] + assert r.zrevrange("a", 0, 1, withscores=True, score_cast_func=int) == [ + (b"a3", 3.0), + (b"a2", 2.0), + ] def test_zrevrangebyscore(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) - assert r.zrevrangebyscore('a', 4, 2) == [b'a4', b'a3', b'a2'] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3, "a4": 4, "a5": 5}) + assert r.zrevrangebyscore("a", 4, 2) == [b"a4", b"a3", b"a2"] # slicing with start/num - assert r.zrevrangebyscore('a', 4, 2, start=1, num=2) == \ - [b'a3', b'a2'] + assert r.zrevrangebyscore("a", 4, 2, start=1, num=2) == [b"a3", b"a2"] # withscores - assert r.zrevrangebyscore('a', 4, 2, withscores=True) == \ - [(b'a4', 4.0), (b'a3', 3.0), (b'a2', 2.0)] + assert r.zrevrangebyscore("a", 4, 2, withscores=True) == [ + (b"a4", 4.0), + (b"a3", 3.0), + (b"a2", 2.0), + ] # custom score function - assert r.zrevrangebyscore('a', 4, 2, withscores=True, - score_cast_func=int) == \ - [(b'a4', 4), (b'a3', 3), (b'a2', 2)] + assert r.zrevrangebyscore("a", 4, 2, withscores=True, score_cast_func=int) == [ + (b"a4", 4), + (b"a3", 3), + (b"a2", 2), + ] def test_zrevrank(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3, 'a4': 4, 'a5': 5}) - assert r.zrevrank('a', 'a1') == 4 - assert r.zrevrank('a', 'a2') == 3 - assert r.zrevrank('a', 'a6') is None + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3, "a4": 4, "a5": 5}) + assert r.zrevrank("a", "a1") == 4 + assert r.zrevrank("a", "a2") == 3 + assert r.zrevrank("a", "a6") is None def test_zscore(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - assert r.zscore('a', 'a1') == 1.0 - assert r.zscore('a', 'a2') == 2.0 - assert r.zscore('a', 'a4') is None + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + assert r.zscore("a", "a1") == 1.0 + assert r.zscore("a", "a2") == 2.0 + assert r.zscore("a", "a4") is None @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_zunion(self, r): - r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) + r.zadd("a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) # sum - assert r.zunion(['a', 'b', 'c']) == \ - [b'a2', b'a4', b'a3', b'a1'] - assert r.zunion(['a', 'b', 'c'], withscores=True) == \ - [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + assert r.zunion(["a", "b", "c"]) == [b"a2", b"a4", b"a3", b"a1"] + assert r.zunion(["a", "b", "c"], withscores=True) == [ + (b"a2", 3), + (b"a4", 4), + (b"a3", 8), + (b"a1", 9), + ] # max - assert r.zunion(['a', 'b', 'c'], aggregate='MAX', withscores=True)\ - == [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + assert r.zunion(["a", "b", "c"], aggregate="MAX", withscores=True) == [ + (b"a2", 2), + (b"a4", 4), + (b"a3", 5), + (b"a1", 6), + ] # min - assert r.zunion(['a', 'b', 'c'], aggregate='MIN', withscores=True)\ - == [(b'a1', 1), (b'a2', 1), (b'a3', 1), (b'a4', 4)] + assert r.zunion(["a", "b", "c"], aggregate="MIN", withscores=True) == [ + (b"a1", 1), + (b"a2", 1), + (b"a3", 1), + (b"a4", 4), + ] # with weight - assert r.zunion({'a': 1, 'b': 2, 'c': 3}, withscores=True)\ - == [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] + assert r.zunion({"a": 1, "b": 2, "c": 3}, withscores=True) == [ + (b"a2", 5), + (b"a4", 12), + (b"a3", 20), + (b"a1", 23), + ] @pytest.mark.onlynoncluster def test_zunionstore_sum(self, r): - r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zunionstore('d', ['a', 'b', 'c']) == 4 - assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a2', 3), (b'a4', 4), (b'a3', 8), (b'a1', 9)] + r.zadd("a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zunionstore("d", ["a", "b", "c"]) == 4 + assert r.zrange("d", 0, -1, withscores=True) == [ + (b"a2", 3), + (b"a4", 4), + (b"a3", 8), + (b"a1", 9), + ] @pytest.mark.onlynoncluster def test_zunionstore_max(self, r): - r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zunionstore('d', ['a', 'b', 'c'], aggregate='MAX') == 4 - assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a2', 2), (b'a4', 4), (b'a3', 5), (b'a1', 6)] + r.zadd("a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zunionstore("d", ["a", "b", "c"], aggregate="MAX") == 4 + assert r.zrange("d", 0, -1, withscores=True) == [ + (b"a2", 2), + (b"a4", 4), + (b"a3", 5), + (b"a1", 6), + ] @pytest.mark.onlynoncluster def test_zunionstore_min(self, r): - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 4}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zunionstore('d', ['a', 'b', 'c'], aggregate='MIN') == 4 - assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a1', 1), (b'a2', 2), (b'a3', 3), (b'a4', 4)] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 4}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zunionstore("d", ["a", "b", "c"], aggregate="MIN") == 4 + assert r.zrange("d", 0, -1, withscores=True) == [ + (b"a1", 1), + (b"a2", 2), + (b"a3", 3), + (b"a4", 4), + ] @pytest.mark.onlynoncluster def test_zunionstore_with_weight(self, r): - r.zadd('a', {'a1': 1, 'a2': 1, 'a3': 1}) - r.zadd('b', {'a1': 2, 'a2': 2, 'a3': 2}) - r.zadd('c', {'a1': 6, 'a3': 5, 'a4': 4}) - assert r.zunionstore('d', {'a': 1, 'b': 2, 'c': 3}) == 4 - assert r.zrange('d', 0, -1, withscores=True) == \ - [(b'a2', 5), (b'a4', 12), (b'a3', 20), (b'a1', 23)] - - @skip_if_server_version_lt('6.1.240') + r.zadd("a", {"a1": 1, "a2": 1, "a3": 1}) + r.zadd("b", {"a1": 2, "a2": 2, "a3": 2}) + r.zadd("c", {"a1": 6, "a3": 5, "a4": 4}) + assert r.zunionstore("d", {"a": 1, "b": 2, "c": 3}) == 4 + assert r.zrange("d", 0, -1, withscores=True) == [ + (b"a2", 5), + (b"a4", 12), + (b"a3", 20), + (b"a1", 23), + ] + + @skip_if_server_version_lt("6.1.240") def test_zmscore(self, r): with pytest.raises(exceptions.DataError): - r.zmscore('invalid_key', []) + r.zmscore("invalid_key", []) - assert r.zmscore('invalid_key', ['invalid_member']) == [None] + assert r.zmscore("invalid_key", ["invalid_member"]) == [None] - r.zadd('a', {'a1': 1, 'a2': 2, 'a3': 3.5}) - assert r.zmscore('a', ['a1', 'a2', 'a3', 'a4']) == \ - [1.0, 2.0, 3.5, None] + r.zadd("a", {"a1": 1, "a2": 2, "a3": 3.5}) + assert r.zmscore("a", ["a1", "a2", "a3", "a4"]) == [1.0, 2.0, 3.5, None] # HYPERLOGLOG TESTS - @skip_if_server_version_lt('2.8.9') + @skip_if_server_version_lt("2.8.9") def test_pfadd(self, r): - members = {b'1', b'2', b'3'} - assert r.pfadd('a', *members) == 1 - assert r.pfadd('a', *members) == 0 - assert r.pfcount('a') == len(members) + members = {b"1", b"2", b"3"} + assert r.pfadd("a", *members) == 1 + assert r.pfadd("a", *members) == 0 + assert r.pfcount("a") == len(members) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.9') + @skip_if_server_version_lt("2.8.9") def test_pfcount(self, r): - members = {b'1', b'2', b'3'} - r.pfadd('a', *members) - assert r.pfcount('a') == len(members) - members_b = {b'2', b'3', b'4'} - r.pfadd('b', *members_b) - assert r.pfcount('b') == len(members_b) - assert r.pfcount('a', 'b') == len(members_b.union(members)) + members = {b"1", b"2", b"3"} + r.pfadd("a", *members) + assert r.pfcount("a") == len(members) + members_b = {b"2", b"3", b"4"} + r.pfadd("b", *members_b) + assert r.pfcount("b") == len(members_b) + assert r.pfcount("a", "b") == len(members_b.union(members)) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.9') + @skip_if_server_version_lt("2.8.9") def test_pfmerge(self, r): - mema = {b'1', b'2', b'3'} - memb = {b'2', b'3', b'4'} - memc = {b'5', b'6', b'7'} - r.pfadd('a', *mema) - r.pfadd('b', *memb) - r.pfadd('c', *memc) - r.pfmerge('d', 'c', 'a') - assert r.pfcount('d') == 6 - r.pfmerge('d', 'b') - assert r.pfcount('d') == 7 + mema = {b"1", b"2", b"3"} + memb = {b"2", b"3", b"4"} + memc = {b"5", b"6", b"7"} + r.pfadd("a", *mema) + r.pfadd("b", *memb) + r.pfadd("c", *memc) + r.pfmerge("d", "c", "a") + assert r.pfcount("d") == 6 + r.pfmerge("d", "b") + assert r.pfcount("d") == 7 # HASH COMMANDS def test_hget_and_hset(self, r): - r.hset('a', mapping={'1': 1, '2': 2, '3': 3}) - assert r.hget('a', '1') == b'1' - assert r.hget('a', '2') == b'2' - assert r.hget('a', '3') == b'3' + r.hset("a", mapping={"1": 1, "2": 2, "3": 3}) + assert r.hget("a", "1") == b"1" + assert r.hget("a", "2") == b"2" + assert r.hget("a", "3") == b"3" # field was updated, redis returns 0 - assert r.hset('a', '2', 5) == 0 - assert r.hget('a', '2') == b'5' + assert r.hset("a", "2", 5) == 0 + assert r.hget("a", "2") == b"5" # field is new, redis returns 1 - assert r.hset('a', '4', 4) == 1 - assert r.hget('a', '4') == b'4' + assert r.hset("a", "4", 4) == 1 + assert r.hget("a", "4") == b"4" # key inside of hash that doesn't exist returns null value - assert r.hget('a', 'b') is None + assert r.hget("a", "b") is None # keys with bool(key) == False - assert r.hset('a', 0, 10) == 1 - assert r.hset('a', '', 10) == 1 + assert r.hset("a", 0, 10) == 1 + assert r.hset("a", "", 10) == 1 def test_hset_with_multi_key_values(self, r): - r.hset('a', mapping={'1': 1, '2': 2, '3': 3}) - assert r.hget('a', '1') == b'1' - assert r.hget('a', '2') == b'2' - assert r.hget('a', '3') == b'3' + r.hset("a", mapping={"1": 1, "2": 2, "3": 3}) + assert r.hget("a", "1") == b"1" + assert r.hget("a", "2") == b"2" + assert r.hget("a", "3") == b"3" - r.hset('b', "foo", "bar", mapping={'1': 1, '2': 2}) - assert r.hget('b', '1') == b'1' - assert r.hget('b', '2') == b'2' - assert r.hget('b', 'foo') == b'bar' + r.hset("b", "foo", "bar", mapping={"1": 1, "2": 2}) + assert r.hget("b", "1") == b"1" + assert r.hget("b", "2") == b"2" + assert r.hget("b", "foo") == b"bar" def test_hset_without_data(self, r): with pytest.raises(exceptions.DataError): r.hset("x") def test_hdel(self, r): - r.hset('a', mapping={'1': 1, '2': 2, '3': 3}) - assert r.hdel('a', '2') == 1 - assert r.hget('a', '2') is None - assert r.hdel('a', '1', '3') == 2 - assert r.hlen('a') == 0 + r.hset("a", mapping={"1": 1, "2": 2, "3": 3}) + assert r.hdel("a", "2") == 1 + assert r.hget("a", "2") is None + assert r.hdel("a", "1", "3") == 2 + assert r.hlen("a") == 0 def test_hexists(self, r): - r.hset('a', mapping={'1': 1, '2': 2, '3': 3}) - assert r.hexists('a', '1') - assert not r.hexists('a', '4') + r.hset("a", mapping={"1": 1, "2": 2, "3": 3}) + assert r.hexists("a", "1") + assert not r.hexists("a", "4") def test_hgetall(self, r): - h = {b'a1': b'1', b'a2': b'2', b'a3': b'3'} - r.hset('a', mapping=h) - assert r.hgetall('a') == h + h = {b"a1": b"1", b"a2": b"2", b"a3": b"3"} + r.hset("a", mapping=h) + assert r.hgetall("a") == h def test_hincrby(self, r): - assert r.hincrby('a', '1') == 1 - assert r.hincrby('a', '1', amount=2) == 3 - assert r.hincrby('a', '1', amount=-2) == 1 + assert r.hincrby("a", "1") == 1 + assert r.hincrby("a", "1", amount=2) == 3 + assert r.hincrby("a", "1", amount=-2) == 1 - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_hincrbyfloat(self, r): - assert r.hincrbyfloat('a', '1') == 1.0 - assert r.hincrbyfloat('a', '1') == 2.0 - assert r.hincrbyfloat('a', '1', 1.2) == 3.2 + assert r.hincrbyfloat("a", "1") == 1.0 + assert r.hincrbyfloat("a", "1") == 2.0 + assert r.hincrbyfloat("a", "1", 1.2) == 3.2 def test_hkeys(self, r): - h = {b'a1': b'1', b'a2': b'2', b'a3': b'3'} - r.hset('a', mapping=h) + h = {b"a1": b"1", b"a2": b"2", b"a3": b"3"} + r.hset("a", mapping=h) local_keys = list(h.keys()) - remote_keys = r.hkeys('a') - assert (sorted(local_keys) == sorted(remote_keys)) + remote_keys = r.hkeys("a") + assert sorted(local_keys) == sorted(remote_keys) def test_hlen(self, r): - r.hset('a', mapping={'1': 1, '2': 2, '3': 3}) - assert r.hlen('a') == 3 + r.hset("a", mapping={"1": 1, "2": 2, "3": 3}) + assert r.hlen("a") == 3 def test_hmget(self, r): - assert r.hset('a', mapping={'a': 1, 'b': 2, 'c': 3}) - assert r.hmget('a', 'a', 'b', 'c') == [b'1', b'2', b'3'] + assert r.hset("a", mapping={"a": 1, "b": 2, "c": 3}) + assert r.hmget("a", "a", "b", "c") == [b"1", b"2", b"3"] def test_hmset(self, r): redis_class = type(r).__name__ - warning_message = (r'^{0}\.hmset\(\) is deprecated\. ' - r'Use {0}\.hset\(\) instead\.$'.format(redis_class)) - h = {b'a': b'1', b'b': b'2', b'c': b'3'} + warning_message = ( + r"^{0}\.hmset\(\) is deprecated\. " + r"Use {0}\.hset\(\) instead\.$".format(redis_class) + ) + h = {b"a": b"1", b"b": b"2", b"c": b"3"} with pytest.warns(DeprecationWarning, match=warning_message): - assert r.hmset('a', h) - assert r.hgetall('a') == h + assert r.hmset("a", h) + assert r.hgetall("a") == h def test_hsetnx(self, r): # Initially set the hash field - assert r.hsetnx('a', '1', 1) - assert r.hget('a', '1') == b'1' - assert not r.hsetnx('a', '1', 2) - assert r.hget('a', '1') == b'1' + assert r.hsetnx("a", "1", 1) + assert r.hget("a", "1") == b"1" + assert not r.hsetnx("a", "1", 2) + assert r.hget("a", "1") == b"1" def test_hvals(self, r): - h = {b'a1': b'1', b'a2': b'2', b'a3': b'3'} - r.hset('a', mapping=h) + h = {b"a1": b"1", b"a2": b"2", b"a3": b"3"} + r.hset("a", mapping=h) local_vals = list(h.values()) - remote_vals = r.hvals('a') + remote_vals = r.hvals("a") assert sorted(local_vals) == sorted(remote_vals) - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_hstrlen(self, r): - r.hset('a', mapping={'1': '22', '2': '333'}) - assert r.hstrlen('a', '1') == 2 - assert r.hstrlen('a', '2') == 3 + r.hset("a", mapping={"1": "22", "2": "333"}) + assert r.hstrlen("a", "1") == 2 + assert r.hstrlen("a", "2") == 3 # SORT def test_sort_basic(self, r): - r.rpush('a', '3', '2', '1', '4') - assert r.sort('a') == [b'1', b'2', b'3', b'4'] + r.rpush("a", "3", "2", "1", "4") + assert r.sort("a") == [b"1", b"2", b"3", b"4"] def test_sort_limited(self, r): - r.rpush('a', '3', '2', '1', '4') - assert r.sort('a', start=1, num=2) == [b'2', b'3'] + r.rpush("a", "3", "2", "1", "4") + assert r.sort("a", start=1, num=2) == [b"2", b"3"] @pytest.mark.onlynoncluster def test_sort_by(self, r): - r['score:1'] = 8 - r['score:2'] = 3 - r['score:3'] = 5 - r.rpush('a', '3', '2', '1') - assert r.sort('a', by='score:*') == [b'2', b'3', b'1'] + r["score:1"] = 8 + r["score:2"] = 3 + r["score:3"] = 5 + r.rpush("a", "3", "2", "1") + assert r.sort("a", by="score:*") == [b"2", b"3", b"1"] @pytest.mark.onlynoncluster def test_sort_get(self, r): - r['user:1'] = 'u1' - r['user:2'] = 'u2' - r['user:3'] = 'u3' - r.rpush('a', '2', '3', '1') - assert r.sort('a', get='user:*') == [b'u1', b'u2', b'u3'] + r["user:1"] = "u1" + r["user:2"] = "u2" + r["user:3"] = "u3" + r.rpush("a", "2", "3", "1") + assert r.sort("a", get="user:*") == [b"u1", b"u2", b"u3"] @pytest.mark.onlynoncluster def test_sort_get_multi(self, r): - r['user:1'] = 'u1' - r['user:2'] = 'u2' - r['user:3'] = 'u3' - r.rpush('a', '2', '3', '1') - assert r.sort('a', get=('user:*', '#')) == \ - [b'u1', b'1', b'u2', b'2', b'u3', b'3'] + r["user:1"] = "u1" + r["user:2"] = "u2" + r["user:3"] = "u3" + r.rpush("a", "2", "3", "1") + assert r.sort("a", get=("user:*", "#")) == [ + b"u1", + b"1", + b"u2", + b"2", + b"u3", + b"3", + ] @pytest.mark.onlynoncluster def test_sort_get_groups_two(self, r): - r['user:1'] = 'u1' - r['user:2'] = 'u2' - r['user:3'] = 'u3' - r.rpush('a', '2', '3', '1') - assert r.sort('a', get=('user:*', '#'), groups=True) == \ - [(b'u1', b'1'), (b'u2', b'2'), (b'u3', b'3')] + r["user:1"] = "u1" + r["user:2"] = "u2" + r["user:3"] = "u3" + r.rpush("a", "2", "3", "1") + assert r.sort("a", get=("user:*", "#"), groups=True) == [ + (b"u1", b"1"), + (b"u2", b"2"), + (b"u3", b"3"), + ] @pytest.mark.onlynoncluster def test_sort_groups_string_get(self, r): - r['user:1'] = 'u1' - r['user:2'] = 'u2' - r['user:3'] = 'u3' - r.rpush('a', '2', '3', '1') + r["user:1"] = "u1" + r["user:2"] = "u2" + r["user:3"] = "u3" + r.rpush("a", "2", "3", "1") with pytest.raises(exceptions.DataError): - r.sort('a', get='user:*', groups=True) + r.sort("a", get="user:*", groups=True) @pytest.mark.onlynoncluster def test_sort_groups_just_one_get(self, r): - r['user:1'] = 'u1' - r['user:2'] = 'u2' - r['user:3'] = 'u3' - r.rpush('a', '2', '3', '1') + r["user:1"] = "u1" + r["user:2"] = "u2" + r["user:3"] = "u3" + r.rpush("a", "2", "3", "1") with pytest.raises(exceptions.DataError): - r.sort('a', get=['user:*'], groups=True) + r.sort("a", get=["user:*"], groups=True) def test_sort_groups_no_get(self, r): - r['user:1'] = 'u1' - r['user:2'] = 'u2' - r['user:3'] = 'u3' - r.rpush('a', '2', '3', '1') + r["user:1"] = "u1" + r["user:2"] = "u2" + r["user:3"] = "u3" + r.rpush("a", "2", "3", "1") with pytest.raises(exceptions.DataError): - r.sort('a', groups=True) + r.sort("a", groups=True) @pytest.mark.onlynoncluster def test_sort_groups_three_gets(self, r): - r['user:1'] = 'u1' - r['user:2'] = 'u2' - r['user:3'] = 'u3' - r['door:1'] = 'd1' - r['door:2'] = 'd2' - r['door:3'] = 'd3' - r.rpush('a', '2', '3', '1') - assert r.sort('a', get=('user:*', 'door:*', '#'), groups=True) == \ - [ - (b'u1', b'd1', b'1'), - (b'u2', b'd2', b'2'), - (b'u3', b'd3', b'3') - ] + r["user:1"] = "u1" + r["user:2"] = "u2" + r["user:3"] = "u3" + r["door:1"] = "d1" + r["door:2"] = "d2" + r["door:3"] = "d3" + r.rpush("a", "2", "3", "1") + assert r.sort("a", get=("user:*", "door:*", "#"), groups=True) == [ + (b"u1", b"d1", b"1"), + (b"u2", b"d2", b"2"), + (b"u3", b"d3", b"3"), + ] def test_sort_desc(self, r): - r.rpush('a', '2', '3', '1') - assert r.sort('a', desc=True) == [b'3', b'2', b'1'] + r.rpush("a", "2", "3", "1") + assert r.sort("a", desc=True) == [b"3", b"2", b"1"] def test_sort_alpha(self, r): - r.rpush('a', 'e', 'c', 'b', 'd', 'a') - assert r.sort('a', alpha=True) == \ - [b'a', b'b', b'c', b'd', b'e'] + r.rpush("a", "e", "c", "b", "d", "a") + assert r.sort("a", alpha=True) == [b"a", b"b", b"c", b"d", b"e"] @pytest.mark.onlynoncluster def test_sort_store(self, r): - r.rpush('a', '2', '3', '1') - assert r.sort('a', store='sorted_values') == 3 - assert r.lrange('sorted_values', 0, -1) == [b'1', b'2', b'3'] + r.rpush("a", "2", "3", "1") + assert r.sort("a", store="sorted_values") == 3 + assert r.lrange("sorted_values", 0, -1) == [b"1", b"2", b"3"] @pytest.mark.onlynoncluster def test_sort_all_options(self, r): - r['user:1:username'] = 'zeus' - r['user:2:username'] = 'titan' - r['user:3:username'] = 'hermes' - r['user:4:username'] = 'hercules' - r['user:5:username'] = 'apollo' - r['user:6:username'] = 'athena' - r['user:7:username'] = 'hades' - r['user:8:username'] = 'dionysus' - - r['user:1:favorite_drink'] = 'yuengling' - r['user:2:favorite_drink'] = 'rum' - r['user:3:favorite_drink'] = 'vodka' - r['user:4:favorite_drink'] = 'milk' - r['user:5:favorite_drink'] = 'pinot noir' - r['user:6:favorite_drink'] = 'water' - r['user:7:favorite_drink'] = 'gin' - r['user:8:favorite_drink'] = 'apple juice' - - r.rpush('gods', '5', '8', '3', '1', '2', '7', '6', '4') - num = r.sort('gods', start=2, num=4, by='user:*:username', - get='user:*:favorite_drink', desc=True, alpha=True, - store='sorted') + r["user:1:username"] = "zeus" + r["user:2:username"] = "titan" + r["user:3:username"] = "hermes" + r["user:4:username"] = "hercules" + r["user:5:username"] = "apollo" + r["user:6:username"] = "athena" + r["user:7:username"] = "hades" + r["user:8:username"] = "dionysus" + + r["user:1:favorite_drink"] = "yuengling" + r["user:2:favorite_drink"] = "rum" + r["user:3:favorite_drink"] = "vodka" + r["user:4:favorite_drink"] = "milk" + r["user:5:favorite_drink"] = "pinot noir" + r["user:6:favorite_drink"] = "water" + r["user:7:favorite_drink"] = "gin" + r["user:8:favorite_drink"] = "apple juice" + + r.rpush("gods", "5", "8", "3", "1", "2", "7", "6", "4") + num = r.sort( + "gods", + start=2, + num=4, + by="user:*:username", + get="user:*:favorite_drink", + desc=True, + alpha=True, + store="sorted", + ) assert num == 4 - assert r.lrange('sorted', 0, 10) == \ - [b'vodka', b'milk', b'gin', b'apple juice'] + assert r.lrange("sorted", 0, 10) == [b"vodka", b"milk", b"gin", b"apple juice"] def test_sort_issue_924(self, r): # Tests for issue https://github.com/andymccurdy/redis-py/issues/924 - r.execute_command('SADD', 'issue#924', 1) - r.execute_command('SORT', 'issue#924') + r.execute_command("SADD", "issue#924", 1) + r.execute_command("SORT", "issue#924") @pytest.mark.onlynoncluster def test_cluster_addslots(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('ADDSLOTS', 1) is True + assert mock_cluster_resp_ok.cluster("ADDSLOTS", 1) is True @pytest.mark.onlynoncluster def test_cluster_count_failure_reports(self, mock_cluster_resp_int): - assert isinstance(mock_cluster_resp_int.cluster( - 'COUNT-FAILURE-REPORTS', 'node'), int) + assert isinstance( + mock_cluster_resp_int.cluster("COUNT-FAILURE-REPORTS", "node"), int + ) @pytest.mark.onlynoncluster def test_cluster_countkeysinslot(self, mock_cluster_resp_int): - assert isinstance(mock_cluster_resp_int.cluster( - 'COUNTKEYSINSLOT', 2), int) + assert isinstance(mock_cluster_resp_int.cluster("COUNTKEYSINSLOT", 2), int) @pytest.mark.onlynoncluster def test_cluster_delslots(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('DELSLOTS', 1) is True + assert mock_cluster_resp_ok.cluster("DELSLOTS", 1) is True @pytest.mark.onlynoncluster def test_cluster_failover(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('FAILOVER', 1) is True + assert mock_cluster_resp_ok.cluster("FAILOVER", 1) is True @pytest.mark.onlynoncluster def test_cluster_forget(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('FORGET', 1) is True + assert mock_cluster_resp_ok.cluster("FORGET", 1) is True @pytest.mark.onlynoncluster def test_cluster_info(self, mock_cluster_resp_info): - assert isinstance(mock_cluster_resp_info.cluster('info'), dict) + assert isinstance(mock_cluster_resp_info.cluster("info"), dict) @pytest.mark.onlynoncluster def test_cluster_keyslot(self, mock_cluster_resp_int): - assert isinstance(mock_cluster_resp_int.cluster( - 'keyslot', 'asdf'), int) + assert isinstance(mock_cluster_resp_int.cluster("keyslot", "asdf"), int) @pytest.mark.onlynoncluster def test_cluster_meet(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('meet', 'ip', 'port', 1) is True + assert mock_cluster_resp_ok.cluster("meet", "ip", "port", 1) is True @pytest.mark.onlynoncluster def test_cluster_nodes(self, mock_cluster_resp_nodes): - assert isinstance(mock_cluster_resp_nodes.cluster('nodes'), dict) + assert isinstance(mock_cluster_resp_nodes.cluster("nodes"), dict) @pytest.mark.onlynoncluster def test_cluster_replicate(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('replicate', 'nodeid') is True + assert mock_cluster_resp_ok.cluster("replicate", "nodeid") is True @pytest.mark.onlynoncluster def test_cluster_reset(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('reset', 'hard') is True + assert mock_cluster_resp_ok.cluster("reset", "hard") is True @pytest.mark.onlynoncluster def test_cluster_saveconfig(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('saveconfig') is True + assert mock_cluster_resp_ok.cluster("saveconfig") is True @pytest.mark.onlynoncluster def test_cluster_setslot(self, mock_cluster_resp_ok): - assert mock_cluster_resp_ok.cluster('setslot', 1, - 'IMPORTING', 'nodeid') is True + assert mock_cluster_resp_ok.cluster("setslot", 1, "IMPORTING", "nodeid") is True @pytest.mark.onlynoncluster def test_cluster_slaves(self, mock_cluster_resp_slaves): - assert isinstance(mock_cluster_resp_slaves.cluster( - 'slaves', 'nodeid'), dict) + assert isinstance(mock_cluster_resp_slaves.cluster("slaves", "nodeid"), dict) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('3.0.0') + @skip_if_server_version_lt("3.0.0") @skip_if_redis_enterprise def test_readwrite(self, r): assert r.readwrite() @pytest.mark.onlynoncluster - @skip_if_server_version_lt('3.0.0') + @skip_if_server_version_lt("3.0.0") def test_readonly_invalid_cluster_state(self, r): with pytest.raises(exceptions.RedisError): r.readonly() @pytest.mark.onlynoncluster - @skip_if_server_version_lt('3.0.0') + @skip_if_server_version_lt("3.0.0") def test_readonly(self, mock_cluster_resp_ok): assert mock_cluster_resp_ok.readonly() is True # GEO COMMANDS - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_geoadd(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - assert r.geoadd('barcelona', values) == 2 - assert r.zcard('barcelona') == 2 + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + assert r.geoadd("barcelona", values) == 2 + assert r.zcard("barcelona") == 2 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_geoadd_nx(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - assert r.geoadd('a', values) == 2 - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + \ - (2.1804738294738, 41.405647879212, 'place3') - assert r.geoadd('a', values, nx=True) == 1 - assert r.zrange('a', 0, -1) == [b'place3', b'place2', b'place1'] - - @skip_if_server_version_lt('6.2.0') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + assert r.geoadd("a", values) == 2 + values = ( + (2.1909389952632, 41.433791470673, "place1") + + (2.1873744593677, 41.406342043777, "place2") + + (2.1804738294738, 41.405647879212, "place3") + ) + assert r.geoadd("a", values, nx=True) == 1 + assert r.zrange("a", 0, -1) == [b"place3", b"place2", b"place1"] + + @skip_if_server_version_lt("6.2.0") def test_geoadd_xx(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') - assert r.geoadd('a', values) == 1 - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - assert r.geoadd('a', values, xx=True) == 0 - assert r.zrange('a', 0, -1) == \ - [b'place1'] - - @skip_if_server_version_lt('6.2.0') + values = (2.1909389952632, 41.433791470673, "place1") + assert r.geoadd("a", values) == 1 + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + assert r.geoadd("a", values, xx=True) == 0 + assert r.zrange("a", 0, -1) == [b"place1"] + + @skip_if_server_version_lt("6.2.0") def test_geoadd_ch(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') - assert r.geoadd('a', values) == 1 - values = (2.1909389952632, 31.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - assert r.geoadd('a', values, ch=True) == 2 - assert r.zrange('a', 0, -1) == \ - [b'place1', b'place2'] - - @skip_if_server_version_lt('3.2.0') + values = (2.1909389952632, 41.433791470673, "place1") + assert r.geoadd("a", values) == 1 + values = (2.1909389952632, 31.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + assert r.geoadd("a", values, ch=True) == 2 + assert r.zrange("a", 0, -1) == [b"place1", b"place2"] + + @skip_if_server_version_lt("3.2.0") def test_geoadd_invalid_params(self, r): with pytest.raises(exceptions.RedisError): - r.geoadd('barcelona', (1, 2)) + r.geoadd("barcelona", (1, 2)) - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_geodist(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - assert r.geoadd('barcelona', values) == 2 - assert r.geodist('barcelona', 'place1', 'place2') == 3067.4157 + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + assert r.geoadd("barcelona", values) == 2 + assert r.geodist("barcelona", "place1", "place2") == 3067.4157 - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_geodist_units(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', values) - assert r.geodist('barcelona', 'place1', 'place2', 'km') == 3.0674 + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + r.geoadd("barcelona", values) + assert r.geodist("barcelona", "place1", "place2", "km") == 3.0674 - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_geodist_missing_one_member(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') - r.geoadd('barcelona', values) - assert r.geodist('barcelona', 'place1', 'missing_member', 'km') is None + values = (2.1909389952632, 41.433791470673, "place1") + r.geoadd("barcelona", values) + assert r.geodist("barcelona", "place1", "missing_member", "km") is None - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_geodist_invalid_units(self, r): with pytest.raises(exceptions.RedisError): - assert r.geodist('x', 'y', 'z', 'inches') + assert r.geodist("x", "y", "z", "inches") - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_geohash(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', values) - assert r.geohash('barcelona', 'place1', 'place2', 'place3') == \ - ['sp3e9yg3kd0', 'sp3e9cbc3t0', None] + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + r.geoadd("barcelona", values) + assert r.geohash("barcelona", "place1", "place2", "place3") == [ + "sp3e9yg3kd0", + "sp3e9cbc3t0", + None, + ] @skip_unless_arch_bits(64) - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_geopos(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', values) + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + r.geoadd("barcelona", values) # redis uses 52 bits precision, hereby small errors may be introduced. - assert r.geopos('barcelona', 'place1', 'place2') == \ - [(2.19093829393386841, 41.43379028184083523), - (2.18737632036209106, 41.40634178640635099)] + assert r.geopos("barcelona", "place1", "place2") == [ + (2.19093829393386841, 41.43379028184083523), + (2.18737632036209106, 41.40634178640635099), + ] - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_geopos_no_value(self, r): - assert r.geopos('barcelona', 'place1', 'place2') == [None, None] + assert r.geopos("barcelona", "place1", "place2") == [None, None] - @skip_if_server_version_lt('3.2.0') - @skip_if_server_version_gte('4.0.0') + @skip_if_server_version_lt("3.2.0") + @skip_if_server_version_gte("4.0.0") def test_old_geopos_no_value(self, r): - assert r.geopos('barcelona', 'place1', 'place2') == [] + assert r.geopos("barcelona", "place1", "place2") == [] - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_geosearch(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, b'\x80place2') + \ - (2.583333, 41.316667, 'place3') - r.geoadd('barcelona', values) - assert r.geosearch('barcelona', longitude=2.191, - latitude=41.433, radius=1000) == [b'place1'] - assert r.geosearch('barcelona', longitude=2.187, - latitude=41.406, radius=1000) == [b'\x80place2'] - assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, - height=1000, width=1000) == [b'place1'] - assert r.geosearch('barcelona', member='place3', radius=100, - unit='km') == [b'\x80place2', b'place1', b'place3'] + values = ( + (2.1909389952632, 41.433791470673, "place1") + + (2.1873744593677, 41.406342043777, b"\x80place2") + + (2.583333, 41.316667, "place3") + ) + r.geoadd("barcelona", values) + assert r.geosearch( + "barcelona", longitude=2.191, latitude=41.433, radius=1000 + ) == [b"place1"] + assert r.geosearch( + "barcelona", longitude=2.187, latitude=41.406, radius=1000 + ) == [b"\x80place2"] + assert r.geosearch( + "barcelona", longitude=2.191, latitude=41.433, height=1000, width=1000 + ) == [b"place1"] + assert r.geosearch("barcelona", member="place3", radius=100, unit="km") == [ + b"\x80place2", + b"place1", + b"place3", + ] # test count - assert r.geosearch('barcelona', member='place3', radius=100, - unit='km', count=2) == [b'place3', b'\x80place2'] - assert r.geosearch('barcelona', member='place3', radius=100, - unit='km', count=1, any=1)[0] \ - in [b'place1', b'place3', b'\x80place2'] + assert r.geosearch( + "barcelona", member="place3", radius=100, unit="km", count=2 + ) == [b"place3", b"\x80place2"] + assert r.geosearch( + "barcelona", member="place3", radius=100, unit="km", count=1, any=1 + )[0] in [b"place1", b"place3", b"\x80place2"] @skip_unless_arch_bits(64) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_geosearch_member(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, b'\x80place2') - - r.geoadd('barcelona', values) - assert r.geosearch('barcelona', member='place1', radius=4000) == \ - [b'\x80place2', b'place1'] - assert r.geosearch('barcelona', member='place1', radius=10) == \ - [b'place1'] - - assert r.geosearch('barcelona', member='place1', radius=4000, - withdist=True, - withcoord=True, - withhash=True) == \ - [[b'\x80place2', 3067.4157, 3471609625421029, - (2.187376320362091, 41.40634178640635)], - [b'place1', 0.0, 3471609698139488, - (2.1909382939338684, 41.433790281840835)]] - - @skip_if_server_version_lt('6.2.0') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + b"\x80place2", + ) + + r.geoadd("barcelona", values) + assert r.geosearch("barcelona", member="place1", radius=4000) == [ + b"\x80place2", + b"place1", + ] + assert r.geosearch("barcelona", member="place1", radius=10) == [b"place1"] + + assert r.geosearch( + "barcelona", + member="place1", + radius=4000, + withdist=True, + withcoord=True, + withhash=True, + ) == [ + [ + b"\x80place2", + 3067.4157, + 3471609625421029, + (2.187376320362091, 41.40634178640635), + ], + [ + b"place1", + 0.0, + 3471609698139488, + (2.1909382939338684, 41.433790281840835), + ], + ] + + @skip_if_server_version_lt("6.2.0") def test_geosearch_sort(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', values) - assert r.geosearch('barcelona', longitude=2.191, - latitude=41.433, radius=3000, sort='ASC') == \ - [b'place1', b'place2'] - assert r.geosearch('barcelona', longitude=2.191, - latitude=41.433, radius=3000, sort='DESC') == \ - [b'place2', b'place1'] + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + r.geoadd("barcelona", values) + assert r.geosearch( + "barcelona", longitude=2.191, latitude=41.433, radius=3000, sort="ASC" + ) == [b"place1", b"place2"] + assert r.geosearch( + "barcelona", longitude=2.191, latitude=41.433, radius=3000, sort="DESC" + ) == [b"place2", b"place1"] @skip_unless_arch_bits(64) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_geosearch_with(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') - r.geoadd('barcelona', values) + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) + r.geoadd("barcelona", values) # test a bunch of combinations to test the parse response # function. - assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, - radius=1, unit='km', withdist=True, - withcoord=True, withhash=True) == \ - [[b'place1', 0.0881, 3471609698139488, - (2.19093829393386841, 41.43379028184083523)]] - assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, - radius=1, unit='km', - withdist=True, withcoord=True) == \ - [[b'place1', 0.0881, - (2.19093829393386841, 41.43379028184083523)]] - assert r.geosearch('barcelona', longitude=2.191, latitude=41.433, - radius=1, unit='km', - withhash=True, withcoord=True) == \ - [[b'place1', 3471609698139488, - (2.19093829393386841, 41.43379028184083523)]] + assert r.geosearch( + "barcelona", + longitude=2.191, + latitude=41.433, + radius=1, + unit="km", + withdist=True, + withcoord=True, + withhash=True, + ) == [ + [ + b"place1", + 0.0881, + 3471609698139488, + (2.19093829393386841, 41.43379028184083523), + ] + ] + assert ( + r.geosearch( + "barcelona", + longitude=2.191, + latitude=41.433, + radius=1, + unit="km", + withdist=True, + withcoord=True, + ) + == [[b"place1", 0.0881, (2.19093829393386841, 41.43379028184083523)]] + ) + assert r.geosearch( + "barcelona", + longitude=2.191, + latitude=41.433, + radius=1, + unit="km", + withhash=True, + withcoord=True, + ) == [ + [b"place1", 3471609698139488, (2.19093829393386841, 41.43379028184083523)] + ] # test no values. - assert r.geosearch('barcelona', longitude=2, latitude=1, - radius=1, unit='km', withdist=True, - withcoord=True, withhash=True) == [] + assert ( + r.geosearch( + "barcelona", + longitude=2, + latitude=1, + radius=1, + unit="km", + withdist=True, + withcoord=True, + withhash=True, + ) + == [] + ) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_geosearch_negative(self, r): # not specifying member nor longitude and latitude with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona') + assert r.geosearch("barcelona") # specifying member and longitude and latitude with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', - member="Paris", longitude=2, latitude=1) + assert r.geosearch("barcelona", member="Paris", longitude=2, latitude=1) # specifying one of longitude and latitude with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', longitude=2) + assert r.geosearch("barcelona", longitude=2) with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', latitude=2) + assert r.geosearch("barcelona", latitude=2) # not specifying radius nor width and height with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', member="Paris") + assert r.geosearch("barcelona", member="Paris") # specifying radius and width and height with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', member="Paris", - radius=3, width=2, height=1) + assert r.geosearch("barcelona", member="Paris", radius=3, width=2, height=1) # specifying one of width and height with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', member="Paris", width=2) + assert r.geosearch("barcelona", member="Paris", width=2) with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', member="Paris", height=2) + assert r.geosearch("barcelona", member="Paris", height=2) # invalid sort with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', - member="Paris", width=2, height=2, sort="wrong") + assert r.geosearch( + "barcelona", member="Paris", width=2, height=2, sort="wrong" + ) # invalid unit with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', - member="Paris", width=2, height=2, unit="miles") + assert r.geosearch( + "barcelona", member="Paris", width=2, height=2, unit="miles" + ) # use any without count with pytest.raises(exceptions.DataError): - assert r.geosearch('barcelona', member='place3', radius=100, any=1) + assert r.geosearch("barcelona", member="place3", radius=100, any=1) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_geosearchstore(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) - r.geosearchstore('places_barcelona', 'barcelona', - longitude=2.191, latitude=41.433, radius=1000) - assert r.zrange('places_barcelona', 0, -1) == [b'place1'] + r.geoadd("barcelona", values) + r.geosearchstore( + "places_barcelona", + "barcelona", + longitude=2.191, + latitude=41.433, + radius=1000, + ) + assert r.zrange("places_barcelona", 0, -1) == [b"place1"] @pytest.mark.onlynoncluster @skip_unless_arch_bits(64) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_geosearchstore_dist(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) - r.geosearchstore('places_barcelona', 'barcelona', - longitude=2.191, latitude=41.433, - radius=1000, storedist=True) + r.geoadd("barcelona", values) + r.geosearchstore( + "places_barcelona", + "barcelona", + longitude=2.191, + latitude=41.433, + radius=1000, + storedist=True, + ) # instead of save the geo score, the distance is saved. - assert r.zscore('places_barcelona', 'place1') == 88.05060698409301 + assert r.zscore("places_barcelona", "place1") == 88.05060698409301 - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_georadius(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, b'\x80place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + b"\x80place2", + ) - r.geoadd('barcelona', values) - assert r.georadius('barcelona', 2.191, 41.433, 1000) == [b'place1'] - assert r.georadius('barcelona', 2.187, 41.406, 1000) == [b'\x80place2'] + r.geoadd("barcelona", values) + assert r.georadius("barcelona", 2.191, 41.433, 1000) == [b"place1"] + assert r.georadius("barcelona", 2.187, 41.406, 1000) == [b"\x80place2"] - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_georadius_no_values(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) - assert r.georadius('barcelona', 1, 2, 1000) == [] + r.geoadd("barcelona", values) + assert r.georadius("barcelona", 1, 2, 1000) == [] - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_georadius_units(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) - assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km') == \ - [b'place1'] + r.geoadd("barcelona", values) + assert r.georadius("barcelona", 2.191, 41.433, 1, unit="km") == [b"place1"] @skip_unless_arch_bits(64) - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_georadius_with(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) + r.geoadd("barcelona", values) # test a bunch of combinations to test the parse response # function. - assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km', - withdist=True, withcoord=True, withhash=True) == \ - [[b'place1', 0.0881, 3471609698139488, - (2.19093829393386841, 41.43379028184083523)]] + assert r.georadius( + "barcelona", + 2.191, + 41.433, + 1, + unit="km", + withdist=True, + withcoord=True, + withhash=True, + ) == [ + [ + b"place1", + 0.0881, + 3471609698139488, + (2.19093829393386841, 41.43379028184083523), + ] + ] - assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km', - withdist=True, withcoord=True) == \ - [[b'place1', 0.0881, - (2.19093829393386841, 41.43379028184083523)]] + assert r.georadius( + "barcelona", 2.191, 41.433, 1, unit="km", withdist=True, withcoord=True + ) == [[b"place1", 0.0881, (2.19093829393386841, 41.43379028184083523)]] - assert r.georadius('barcelona', 2.191, 41.433, 1, unit='km', - withhash=True, withcoord=True) == \ - [[b'place1', 3471609698139488, - (2.19093829393386841, 41.43379028184083523)]] + assert r.georadius( + "barcelona", 2.191, 41.433, 1, unit="km", withhash=True, withcoord=True + ) == [ + [b"place1", 3471609698139488, (2.19093829393386841, 41.43379028184083523)] + ] # test no values. - assert r.georadius('barcelona', 2, 1, 1, unit='km', - withdist=True, withcoord=True, withhash=True) == [] + assert ( + r.georadius( + "barcelona", + 2, + 1, + 1, + unit="km", + withdist=True, + withcoord=True, + withhash=True, + ) + == [] + ) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_georadius_count(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) - assert r.georadius('barcelona', 2.191, 41.433, 3000, count=1) == \ - [b'place1'] - assert r.georadius('barcelona', 2.191, 41.433, 3000, - count=1, any=True) == \ - [b'place2'] + r.geoadd("barcelona", values) + assert r.georadius("barcelona", 2.191, 41.433, 3000, count=1) == [b"place1"] + assert r.georadius("barcelona", 2.191, 41.433, 3000, count=1, any=True) == [ + b"place2" + ] - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_georadius_sort(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) - assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='ASC') == \ - [b'place1', b'place2'] - assert r.georadius('barcelona', 2.191, 41.433, 3000, sort='DESC') == \ - [b'place2', b'place1'] + r.geoadd("barcelona", values) + assert r.georadius("barcelona", 2.191, 41.433, 3000, sort="ASC") == [ + b"place1", + b"place2", + ] + assert r.georadius("barcelona", 2.191, 41.433, 3000, sort="DESC") == [ + b"place2", + b"place1", + ] @pytest.mark.onlynoncluster - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_georadius_store(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) - r.georadius('barcelona', 2.191, 41.433, 1000, store='places_barcelona') - assert r.zrange('places_barcelona', 0, -1) == [b'place1'] + r.geoadd("barcelona", values) + r.georadius("barcelona", 2.191, 41.433, 1000, store="places_barcelona") + assert r.zrange("places_barcelona", 0, -1) == [b"place1"] @pytest.mark.onlynoncluster @skip_unless_arch_bits(64) - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_georadius_store_dist(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, 'place2') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + "place2", + ) - r.geoadd('barcelona', values) - r.georadius('barcelona', 2.191, 41.433, 1000, - store_dist='places_barcelona') + r.geoadd("barcelona", values) + r.georadius("barcelona", 2.191, 41.433, 1000, store_dist="places_barcelona") # instead of save the geo score, the distance is saved. - assert r.zscore('places_barcelona', 'place1') == 88.05060698409301 + assert r.zscore("places_barcelona", "place1") == 88.05060698409301 @skip_unless_arch_bits(64) - @skip_if_server_version_lt('3.2.0') + @skip_if_server_version_lt("3.2.0") def test_georadiusmember(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, b'\x80place2') - - r.geoadd('barcelona', values) - assert r.georadiusbymember('barcelona', 'place1', 4000) == \ - [b'\x80place2', b'place1'] - assert r.georadiusbymember('barcelona', 'place1', 10) == [b'place1'] - - assert r.georadiusbymember('barcelona', 'place1', 4000, - withdist=True, withcoord=True, - withhash=True) == \ - [[b'\x80place2', 3067.4157, 3471609625421029, - (2.187376320362091, 41.40634178640635)], - [b'place1', 0.0, 3471609698139488, - (2.1909382939338684, 41.433790281840835)]] - - @skip_if_server_version_lt('6.2.0') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + b"\x80place2", + ) + + r.geoadd("barcelona", values) + assert r.georadiusbymember("barcelona", "place1", 4000) == [ + b"\x80place2", + b"place1", + ] + assert r.georadiusbymember("barcelona", "place1", 10) == [b"place1"] + + assert r.georadiusbymember( + "barcelona", "place1", 4000, withdist=True, withcoord=True, withhash=True + ) == [ + [ + b"\x80place2", + 3067.4157, + 3471609625421029, + (2.187376320362091, 41.40634178640635), + ], + [ + b"place1", + 0.0, + 3471609698139488, + (2.1909382939338684, 41.433790281840835), + ], + ] + + @skip_if_server_version_lt("6.2.0") def test_georadiusmember_count(self, r): - values = (2.1909389952632, 41.433791470673, 'place1') + \ - (2.1873744593677, 41.406342043777, b'\x80place2') - r.geoadd('barcelona', values) - assert r.georadiusbymember('barcelona', 'place1', 4000, - count=1, any=True) == \ - [b'\x80place2'] - - @skip_if_server_version_lt('5.0.0') + values = (2.1909389952632, 41.433791470673, "place1") + ( + 2.1873744593677, + 41.406342043777, + b"\x80place2", + ) + r.geoadd("barcelona", values) + assert r.georadiusbymember("barcelona", "place1", 4000, count=1, any=True) == [ + b"\x80place2" + ] + + @skip_if_server_version_lt("5.0.0") def test_xack(self, r): - stream = 'stream' - group = 'group' - consumer = 'consumer' + stream = "stream" + group = "group" + consumer = "consumer" # xack on a stream that doesn't exist - assert r.xack(stream, group, '0-0') == 0 + assert r.xack(stream, group, "0-0") == 0 - m1 = r.xadd(stream, {'one': 'one'}) - m2 = r.xadd(stream, {'two': 'two'}) - m3 = r.xadd(stream, {'three': 'three'}) + m1 = r.xadd(stream, {"one": "one"}) + m2 = r.xadd(stream, {"two": "two"}) + m3 = r.xadd(stream, {"three": "three"}) # xack on a group that doesn't exist assert r.xack(stream, group, m1) == 0 r.xgroup_create(stream, group, 0) - r.xreadgroup(group, consumer, streams={stream: '>'}) + r.xreadgroup(group, consumer, streams={stream: ">"}) # xack returns the number of ack'd elements assert r.xack(stream, group, m1) == 1 assert r.xack(stream, group, m2, m3) == 2 - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xadd(self, r): - stream = 'stream' - message_id = r.xadd(stream, {'foo': 'bar'}) - assert re.match(br'[0-9]+\-[0-9]+', message_id) + stream = "stream" + message_id = r.xadd(stream, {"foo": "bar"}) + assert re.match(br"[0-9]+\-[0-9]+", message_id) # explicit message id - message_id = b'9999999999999999999-0' - assert message_id == r.xadd(stream, {'foo': 'bar'}, id=message_id) + message_id = b"9999999999999999999-0" + assert message_id == r.xadd(stream, {"foo": "bar"}, id=message_id) # with maxlen, the list evicts the first message - r.xadd(stream, {'foo': 'bar'}, maxlen=2, approximate=False) + r.xadd(stream, {"foo": "bar"}, maxlen=2, approximate=False) assert r.xlen(stream) == 2 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_xadd_nomkstream(self, r): # nomkstream option - stream = 'stream' - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'some': 'other'}, nomkstream=False) + stream = "stream" + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"some": "other"}, nomkstream=False) assert r.xlen(stream) == 2 - r.xadd(stream, {'some': 'other'}, nomkstream=True) + r.xadd(stream, {"some": "other"}, nomkstream=True) assert r.xlen(stream) == 3 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_xadd_minlen_and_limit(self, r): - stream = 'stream' + stream = "stream" - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) # Future self: No limits without approximate, according to the api with pytest.raises(redis.ResponseError): - assert r.xadd(stream, {'foo': 'bar'}, maxlen=3, - approximate=False, limit=2) + assert r.xadd(stream, {"foo": "bar"}, maxlen=3, approximate=False, limit=2) # limit can not be provided without maxlen or minid with pytest.raises(redis.ResponseError): - assert r.xadd(stream, {'foo': 'bar'}, limit=2) + assert r.xadd(stream, {"foo": "bar"}, limit=2) # maxlen with a limit - assert r.xadd(stream, {'foo': 'bar'}, maxlen=3, - approximate=True, limit=2) + assert r.xadd(stream, {"foo": "bar"}, maxlen=3, approximate=True, limit=2) r.delete(stream) # maxlen and minid can not be provided together with pytest.raises(redis.DataError): - assert r.xadd(stream, {'foo': 'bar'}, maxlen=3, - minid="sometestvalue") + assert r.xadd(stream, {"foo": "bar"}, maxlen=3, minid="sometestvalue") # minid with a limit - m1 = r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - assert r.xadd(stream, {'foo': 'bar'}, approximate=True, - minid=m1, limit=3) + m1 = r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + assert r.xadd(stream, {"foo": "bar"}, approximate=True, minid=m1, limit=3) # pure minid - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - m4 = r.xadd(stream, {'foo': 'bar'}) - assert r.xadd(stream, {'foo': 'bar'}, approximate=False, minid=m4) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + m4 = r.xadd(stream, {"foo": "bar"}) + assert r.xadd(stream, {"foo": "bar"}, approximate=False, minid=m4) # minid approximate - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - m3 = r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - assert r.xadd(stream, {'foo': 'bar'}, approximate=True, minid=m3) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + m3 = r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + assert r.xadd(stream, {"foo": "bar"}, approximate=True, minid=m3) - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_xautoclaim(self, r): - stream = 'stream' - group = 'group' - consumer1 = 'consumer1' - consumer2 = 'consumer2' + stream = "stream" + group = "group" + consumer1 = "consumer1" + consumer2 = "consumer2" - message_id1 = r.xadd(stream, {'john': 'wick'}) - message_id2 = r.xadd(stream, {'johny': 'deff'}) + message_id1 = r.xadd(stream, {"john": "wick"}) + message_id2 = r.xadd(stream, {"johny": "deff"}) message = get_stream_message(r, stream, message_id1) r.xgroup_create(stream, group, 0) @@ -3084,70 +3348,78 @@ def test_xautoclaim(self, r): assert response == [] # read the group as consumer1 to initially claim the messages - r.xreadgroup(group, consumer1, streams={stream: '>'}) + r.xreadgroup(group, consumer1, streams={stream: ">"}) # claim one message as consumer2 - response = r.xautoclaim(stream, group, consumer2, - min_idle_time=0, count=1) + response = r.xautoclaim(stream, group, consumer2, min_idle_time=0, count=1) assert response == [message] # reclaim the messages as consumer1, but use the justid argument # which only returns message ids - assert r.xautoclaim(stream, group, consumer1, min_idle_time=0, - start_id=0, justid=True) == \ - [message_id1, message_id2] - assert r.xautoclaim(stream, group, consumer1, min_idle_time=0, - start_id=message_id2, justid=True) == \ - [message_id2] - - @skip_if_server_version_lt('6.2.0') + assert r.xautoclaim( + stream, group, consumer1, min_idle_time=0, start_id=0, justid=True + ) == [message_id1, message_id2] + assert r.xautoclaim( + stream, group, consumer1, min_idle_time=0, start_id=message_id2, justid=True + ) == [message_id2] + + @skip_if_server_version_lt("6.2.0") def test_xautoclaim_negative(self, r): - stream = 'stream' - group = 'group' - consumer = 'consumer' + stream = "stream" + group = "group" + consumer = "consumer" with pytest.raises(redis.DataError): r.xautoclaim(stream, group, consumer, min_idle_time=-1) with pytest.raises(ValueError): r.xautoclaim(stream, group, consumer, min_idle_time="wrong") with pytest.raises(redis.DataError): - r.xautoclaim(stream, group, consumer, min_idle_time=0, - count=-1) + r.xautoclaim(stream, group, consumer, min_idle_time=0, count=-1) - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xclaim(self, r): - stream = 'stream' - group = 'group' - consumer1 = 'consumer1' - consumer2 = 'consumer2' - message_id = r.xadd(stream, {'john': 'wick'}) + stream = "stream" + group = "group" + consumer1 = "consumer1" + consumer2 = "consumer2" + message_id = r.xadd(stream, {"john": "wick"}) message = get_stream_message(r, stream, message_id) r.xgroup_create(stream, group, 0) # trying to claim a message that isn't already pending doesn't # do anything - response = r.xclaim(stream, group, consumer2, - min_idle_time=0, message_ids=(message_id,)) + response = r.xclaim( + stream, group, consumer2, min_idle_time=0, message_ids=(message_id,) + ) assert response == [] # read the group as consumer1 to initially claim the messages - r.xreadgroup(group, consumer1, streams={stream: '>'}) + r.xreadgroup(group, consumer1, streams={stream: ">"}) # claim the message as consumer2 - response = r.xclaim(stream, group, consumer2, - min_idle_time=0, message_ids=(message_id,)) + response = r.xclaim( + stream, group, consumer2, min_idle_time=0, message_ids=(message_id,) + ) assert response[0] == message # reclaim the message as consumer1, but use the justid argument # which only returns message ids - assert r.xclaim(stream, group, consumer1, - min_idle_time=0, message_ids=(message_id,), - justid=True) == [message_id] + assert ( + r.xclaim( + stream, + group, + consumer1, + min_idle_time=0, + message_ids=(message_id,), + justid=True, + ) + == [message_id] + ) - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xclaim_trimmed(self, r): # xclaim should not raise an exception if the item is not there - stream = 'stream' - group = 'group' + stream = "stream" + group = "group" r.xgroup_create(stream, group, id="$", mkstream=True) @@ -3156,57 +3428,59 @@ def test_xclaim_trimmed(self, r): sid2 = r.xadd(stream, {"item": 0}) # read them from consumer1 - r.xreadgroup(group, 'consumer1', {stream: ">"}) + r.xreadgroup(group, "consumer1", {stream: ">"}) # add a 3rd and trim the stream down to 2 items r.xadd(stream, {"item": 3}, maxlen=2, approximate=False) # xclaim them from consumer2 # the item that is still in the stream should be returned - item = r.xclaim(stream, group, 'consumer2', 0, [sid1, sid2]) + item = r.xclaim(stream, group, "consumer2", 0, [sid1, sid2]) assert len(item) == 2 assert item[0] == (None, None) assert item[1][0] == sid2 - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xdel(self, r): - stream = 'stream' + stream = "stream" # deleting from an empty stream doesn't do anything assert r.xdel(stream, 1) == 0 - m1 = r.xadd(stream, {'foo': 'bar'}) - m2 = r.xadd(stream, {'foo': 'bar'}) - m3 = r.xadd(stream, {'foo': 'bar'}) + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) + m3 = r.xadd(stream, {"foo": "bar"}) # xdel returns the number of deleted elements assert r.xdel(stream, m1) == 1 assert r.xdel(stream, m2, m3) == 2 - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xgroup_create(self, r): # tests xgroup_create and xinfo_groups - stream = 'stream' - group = 'group' - r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + r.xadd(stream, {"foo": "bar"}) # no group is setup yet, no info to obtain assert r.xinfo_groups(stream) == [] assert r.xgroup_create(stream, group, 0) - expected = [{ - 'name': group.encode(), - 'consumers': 0, - 'pending': 0, - 'last-delivered-id': b'0-0' - }] + expected = [ + { + "name": group.encode(), + "consumers": 0, + "pending": 0, + "last-delivered-id": b"0-0", + } + ] assert r.xinfo_groups(stream) == expected - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xgroup_create_mkstream(self, r): # tests xgroup_create and xinfo_groups - stream = 'stream' - group = 'group' + stream = "stream" + group = "group" # an error is raised if a group is created on a stream that # doesn't already exist @@ -3216,53 +3490,55 @@ def test_xgroup_create_mkstream(self, r): # however, with mkstream=True, the underlying stream is created # automatically assert r.xgroup_create(stream, group, 0, mkstream=True) - expected = [{ - 'name': group.encode(), - 'consumers': 0, - 'pending': 0, - 'last-delivered-id': b'0-0' - }] + expected = [ + { + "name": group.encode(), + "consumers": 0, + "pending": 0, + "last-delivered-id": b"0-0", + } + ] assert r.xinfo_groups(stream) == expected - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xgroup_delconsumer(self, r): - stream = 'stream' - group = 'group' - consumer = 'consumer' - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + consumer = "consumer" + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) r.xgroup_create(stream, group, 0) # a consumer that hasn't yet read any messages doesn't do anything assert r.xgroup_delconsumer(stream, group, consumer) == 0 # read all messages from the group - r.xreadgroup(group, consumer, streams={stream: '>'}) + r.xreadgroup(group, consumer, streams={stream: ">"}) # deleting the consumer should return 2 pending messages assert r.xgroup_delconsumer(stream, group, consumer) == 2 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_xgroup_createconsumer(self, r): - stream = 'stream' - group = 'group' - consumer = 'consumer' - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + consumer = "consumer" + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) r.xgroup_create(stream, group, 0) assert r.xgroup_createconsumer(stream, group, consumer) == 1 # read all messages from the group - r.xreadgroup(group, consumer, streams={stream: '>'}) + r.xreadgroup(group, consumer, streams={stream: ">"}) # deleting the consumer should return 2 pending messages assert r.xgroup_delconsumer(stream, group, consumer) == 2 - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xgroup_destroy(self, r): - stream = 'stream' - group = 'group' - r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + r.xadd(stream, {"foo": "bar"}) # destroying a nonexistent group returns False assert not r.xgroup_destroy(stream, group) @@ -3270,198 +3546,189 @@ def test_xgroup_destroy(self, r): r.xgroup_create(stream, group, 0) assert r.xgroup_destroy(stream, group) - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xgroup_setid(self, r): - stream = 'stream' - group = 'group' - message_id = r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + message_id = r.xadd(stream, {"foo": "bar"}) r.xgroup_create(stream, group, 0) # advance the last_delivered_id to the message_id r.xgroup_setid(stream, group, message_id) - expected = [{ - 'name': group.encode(), - 'consumers': 0, - 'pending': 0, - 'last-delivered-id': message_id - }] + expected = [ + { + "name": group.encode(), + "consumers": 0, + "pending": 0, + "last-delivered-id": message_id, + } + ] assert r.xinfo_groups(stream) == expected - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xinfo_consumers(self, r): - stream = 'stream' - group = 'group' - consumer1 = 'consumer1' - consumer2 = 'consumer2' - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + consumer1 = "consumer1" + consumer2 = "consumer2" + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) r.xgroup_create(stream, group, 0) - r.xreadgroup(group, consumer1, streams={stream: '>'}, count=1) - r.xreadgroup(group, consumer2, streams={stream: '>'}) + r.xreadgroup(group, consumer1, streams={stream: ">"}, count=1) + r.xreadgroup(group, consumer2, streams={stream: ">"}) info = r.xinfo_consumers(stream, group) assert len(info) == 2 expected = [ - {'name': consumer1.encode(), 'pending': 1}, - {'name': consumer2.encode(), 'pending': 2}, + {"name": consumer1.encode(), "pending": 1}, + {"name": consumer2.encode(), "pending": 2}, ] # we can't determine the idle time, so just make sure it's an int - assert isinstance(info[0].pop('idle'), int) - assert isinstance(info[1].pop('idle'), int) + assert isinstance(info[0].pop("idle"), int) + assert isinstance(info[1].pop("idle"), int) assert info == expected - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xinfo_stream(self, r): - stream = 'stream' - m1 = r.xadd(stream, {'foo': 'bar'}) - m2 = r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) info = r.xinfo_stream(stream) - assert info['length'] == 2 - assert info['first-entry'] == get_stream_message(r, stream, m1) - assert info['last-entry'] == get_stream_message(r, stream, m2) + assert info["length"] == 2 + assert info["first-entry"] == get_stream_message(r, stream, m1) + assert info["last-entry"] == get_stream_message(r, stream, m2) - @skip_if_server_version_lt('6.0.0') + @skip_if_server_version_lt("6.0.0") def test_xinfo_stream_full(self, r): - stream = 'stream' - group = 'group' - m1 = r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + m1 = r.xadd(stream, {"foo": "bar"}) r.xgroup_create(stream, group, 0) info = r.xinfo_stream(stream, full=True) - assert info['length'] == 1 - assert m1 in info['entries'] - assert len(info['groups']) == 1 + assert info["length"] == 1 + assert m1 in info["entries"] + assert len(info["groups"]) == 1 - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xlen(self, r): - stream = 'stream' + stream = "stream" assert r.xlen(stream) == 0 - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) assert r.xlen(stream) == 2 - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xpending(self, r): - stream = 'stream' - group = 'group' - consumer1 = 'consumer1' - consumer2 = 'consumer2' - m1 = r.xadd(stream, {'foo': 'bar'}) - m2 = r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + consumer1 = "consumer1" + consumer2 = "consumer2" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) r.xgroup_create(stream, group, 0) # xpending on a group that has no consumers yet - expected = { - 'pending': 0, - 'min': None, - 'max': None, - 'consumers': [] - } + expected = {"pending": 0, "min": None, "max": None, "consumers": []} assert r.xpending(stream, group) == expected # read 1 message from the group with each consumer - r.xreadgroup(group, consumer1, streams={stream: '>'}, count=1) - r.xreadgroup(group, consumer2, streams={stream: '>'}, count=1) + r.xreadgroup(group, consumer1, streams={stream: ">"}, count=1) + r.xreadgroup(group, consumer2, streams={stream: ">"}, count=1) expected = { - 'pending': 2, - 'min': m1, - 'max': m2, - 'consumers': [ - {'name': consumer1.encode(), 'pending': 1}, - {'name': consumer2.encode(), 'pending': 1}, - ] + "pending": 2, + "min": m1, + "max": m2, + "consumers": [ + {"name": consumer1.encode(), "pending": 1}, + {"name": consumer2.encode(), "pending": 1}, + ], } assert r.xpending(stream, group) == expected - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xpending_range(self, r): - stream = 'stream' - group = 'group' - consumer1 = 'consumer1' - consumer2 = 'consumer2' - m1 = r.xadd(stream, {'foo': 'bar'}) - m2 = r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + consumer1 = "consumer1" + consumer2 = "consumer2" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) r.xgroup_create(stream, group, 0) # xpending range on a group that has no consumers yet - assert r.xpending_range(stream, group, min='-', max='+', count=5) == [] + assert r.xpending_range(stream, group, min="-", max="+", count=5) == [] # read 1 message from the group with each consumer - r.xreadgroup(group, consumer1, streams={stream: '>'}, count=1) - r.xreadgroup(group, consumer2, streams={stream: '>'}, count=1) + r.xreadgroup(group, consumer1, streams={stream: ">"}, count=1) + r.xreadgroup(group, consumer2, streams={stream: ">"}, count=1) - response = r.xpending_range(stream, group, - min='-', max='+', count=5) + response = r.xpending_range(stream, group, min="-", max="+", count=5) assert len(response) == 2 - assert response[0]['message_id'] == m1 - assert response[0]['consumer'] == consumer1.encode() - assert response[1]['message_id'] == m2 - assert response[1]['consumer'] == consumer2.encode() + assert response[0]["message_id"] == m1 + assert response[0]["consumer"] == consumer1.encode() + assert response[1]["message_id"] == m2 + assert response[1]["consumer"] == consumer2.encode() # test with consumer name - response = r.xpending_range(stream, group, - min='-', max='+', count=5, - consumername=consumer1) - assert response[0]['message_id'] == m1 - assert response[0]['consumer'] == consumer1.encode() + response = r.xpending_range( + stream, group, min="-", max="+", count=5, consumername=consumer1 + ) + assert response[0]["message_id"] == m1 + assert response[0]["consumer"] == consumer1.encode() - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_xpending_range_idle(self, r): - stream = 'stream' - group = 'group' - consumer1 = 'consumer1' - consumer2 = 'consumer2' - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + group = "group" + consumer1 = "consumer1" + consumer2 = "consumer2" + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) r.xgroup_create(stream, group, 0) # read 1 message from the group with each consumer - r.xreadgroup(group, consumer1, streams={stream: '>'}, count=1) - r.xreadgroup(group, consumer2, streams={stream: '>'}, count=1) + r.xreadgroup(group, consumer1, streams={stream: ">"}, count=1) + r.xreadgroup(group, consumer2, streams={stream: ">"}, count=1) - response = r.xpending_range(stream, group, - min='-', max='+', count=5) + response = r.xpending_range(stream, group, min="-", max="+", count=5) assert len(response) == 2 - response = r.xpending_range(stream, group, - min='-', max='+', count=5, idle=1000) + response = r.xpending_range(stream, group, min="-", max="+", count=5, idle=1000) assert len(response) == 0 def test_xpending_range_negative(self, r): - stream = 'stream' - group = 'group' + stream = "stream" + group = "group" with pytest.raises(redis.DataError): - r.xpending_range(stream, group, min='-', max='+', count=None) + r.xpending_range(stream, group, min="-", max="+", count=None) with pytest.raises(ValueError): - r.xpending_range(stream, group, min='-', max='+', count="one") + r.xpending_range(stream, group, min="-", max="+", count="one") with pytest.raises(redis.DataError): - r.xpending_range(stream, group, min='-', max='+', count=-1) + r.xpending_range(stream, group, min="-", max="+", count=-1) with pytest.raises(ValueError): - r.xpending_range(stream, group, min='-', max='+', count=5, - idle="one") + r.xpending_range(stream, group, min="-", max="+", count=5, idle="one") with pytest.raises(redis.exceptions.ResponseError): - r.xpending_range(stream, group, min='-', max='+', count=5, - idle=1.5) + r.xpending_range(stream, group, min="-", max="+", count=5, idle=1.5) with pytest.raises(redis.DataError): - r.xpending_range(stream, group, min='-', max='+', count=5, - idle=-1) + r.xpending_range(stream, group, min="-", max="+", count=5, idle=-1) with pytest.raises(redis.DataError): - r.xpending_range(stream, group, min=None, max=None, count=None, - idle=0) + r.xpending_range(stream, group, min=None, max=None, count=None, idle=0) with pytest.raises(redis.DataError): - r.xpending_range(stream, group, min=None, max=None, count=None, - consumername=0) + r.xpending_range( + stream, group, min=None, max=None, count=None, consumername=0 + ) - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xrange(self, r): - stream = 'stream' - m1 = r.xadd(stream, {'foo': 'bar'}) - m2 = r.xadd(stream, {'foo': 'bar'}) - m3 = r.xadd(stream, {'foo': 'bar'}) - m4 = r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) + m3 = r.xadd(stream, {"foo": "bar"}) + m4 = r.xadd(stream, {"foo": "bar"}) def get_ids(results): return [result[0] for result in results] @@ -3478,11 +3745,11 @@ def get_ids(results): results = r.xrange(stream, max=m2, count=1) assert get_ids(results) == [m1] - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xread(self, r): - stream = 'stream' - m1 = r.xadd(stream, {'foo': 'bar'}) - m2 = r.xadd(stream, {'bing': 'baz'}) + stream = "stream" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"bing": "baz"}) expected = [ [ @@ -3490,7 +3757,7 @@ def test_xread(self, r): [ get_stream_message(r, stream, m1), get_stream_message(r, stream, m2), - ] + ], ] ] # xread starting at 0 returns both messages @@ -3501,7 +3768,7 @@ def test_xread(self, r): stream.encode(), [ get_stream_message(r, stream, m1), - ] + ], ] ] # xread starting at 0 and count=1 returns only the first message @@ -3512,7 +3779,7 @@ def test_xread(self, r): stream.encode(), [ get_stream_message(r, stream, m2), - ] + ], ] ] # xread starting at m1 returns only the second message @@ -3521,13 +3788,13 @@ def test_xread(self, r): # xread starting at the last message returns an empty list assert r.xread(streams={stream: m2}) == [] - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xreadgroup(self, r): - stream = 'stream' - group = 'group' - consumer = 'consumer' - m1 = r.xadd(stream, {'foo': 'bar'}) - m2 = r.xadd(stream, {'bing': 'baz'}) + stream = "stream" + group = "group" + consumer = "consumer" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"bing": "baz"}) r.xgroup_create(stream, group, 0) expected = [ @@ -3536,11 +3803,11 @@ def test_xreadgroup(self, r): [ get_stream_message(r, stream, m1), get_stream_message(r, stream, m2), - ] + ], ] ] # xread starting at 0 returns both messages - assert r.xreadgroup(group, consumer, streams={stream: '>'}) == expected + assert r.xreadgroup(group, consumer, streams={stream: ">"}) == expected r.xgroup_destroy(stream, group) r.xgroup_create(stream, group, 0) @@ -3550,34 +3817,34 @@ def test_xreadgroup(self, r): stream.encode(), [ get_stream_message(r, stream, m1), - ] + ], ] ] # xread with count=1 returns only the first message - assert r.xreadgroup(group, consumer, - streams={stream: '>'}, count=1) == expected + assert r.xreadgroup(group, consumer, streams={stream: ">"}, count=1) == expected r.xgroup_destroy(stream, group) # create the group using $ as the last id meaning subsequent reads # will only find messages added after this - r.xgroup_create(stream, group, '$') + r.xgroup_create(stream, group, "$") expected = [] # xread starting after the last message returns an empty message list - assert r.xreadgroup(group, consumer, streams={stream: '>'}) == expected + assert r.xreadgroup(group, consumer, streams={stream: ">"}) == expected # xreadgroup with noack does not have any items in the PEL r.xgroup_destroy(stream, group) - r.xgroup_create(stream, group, '0') - assert len(r.xreadgroup(group, consumer, streams={stream: '>'}, - noack=True)[0][1]) == 2 + r.xgroup_create(stream, group, "0") + assert ( + len(r.xreadgroup(group, consumer, streams={stream: ">"}, noack=True)[0][1]) + == 2 + ) # now there should be nothing pending - assert len(r.xreadgroup(group, consumer, - streams={stream: '0'})[0][1]) == 0 + assert len(r.xreadgroup(group, consumer, streams={stream: "0"})[0][1]) == 0 r.xgroup_destroy(stream, group) - r.xgroup_create(stream, group, '0') + r.xgroup_create(stream, group, "0") # delete all the messages in the stream expected = [ [ @@ -3585,20 +3852,20 @@ def test_xreadgroup(self, r): [ (m1, {}), (m2, {}), - ] + ], ] ] - r.xreadgroup(group, consumer, streams={stream: '>'}) + r.xreadgroup(group, consumer, streams={stream: ">"}) r.xtrim(stream, 0) - assert r.xreadgroup(group, consumer, streams={stream: '0'}) == expected + assert r.xreadgroup(group, consumer, streams={stream: "0"}) == expected - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xrevrange(self, r): - stream = 'stream' - m1 = r.xadd(stream, {'foo': 'bar'}) - m2 = r.xadd(stream, {'foo': 'bar'}) - m3 = r.xadd(stream, {'foo': 'bar'}) - m4 = r.xadd(stream, {'foo': 'bar'}) + stream = "stream" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) + m3 = r.xadd(stream, {"foo": "bar"}) + m4 = r.xadd(stream, {"foo": "bar"}) def get_ids(results): return [result[0] for result in results] @@ -3615,17 +3882,17 @@ def get_ids(results): results = r.xrevrange(stream, min=m2, count=1) assert get_ids(results) == [m4] - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_xtrim(self, r): - stream = 'stream' + stream = "stream" # trimming an empty key doesn't do anything assert r.xtrim(stream, 1000) == 0 - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) # trimming an amount large than the number of messages # doesn't do anything @@ -3634,14 +3901,14 @@ def test_xtrim(self, r): # 1 message is trimmed assert r.xtrim(stream, 3, approximate=False) == 1 - @skip_if_server_version_lt('6.2.4') + @skip_if_server_version_lt("6.2.4") def test_xtrim_minlen_and_length_args(self, r): - stream = 'stream' + stream = "stream" - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) # Future self: No limits without approximate, according to the api with pytest.raises(redis.ResponseError): @@ -3655,99 +3922,105 @@ def test_xtrim_minlen_and_length_args(self, r): assert r.xtrim(stream, maxlen=3, minid="sometestvalue") # minid with a limit - m1 = r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + m1 = r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) assert r.xtrim(stream, None, approximate=True, minid=m1, limit=3) == 0 # pure minid - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - m4 = r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + m4 = r.xadd(stream, {"foo": "bar"}) assert r.xtrim(stream, None, approximate=False, minid=m4) == 7 # minid approximate - r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) - m3 = r.xadd(stream, {'foo': 'bar'}) - r.xadd(stream, {'foo': 'bar'}) + r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) + m3 = r.xadd(stream, {"foo": "bar"}) + r.xadd(stream, {"foo": "bar"}) assert r.xtrim(stream, None, approximate=True, minid=m3) == 0 def test_bitfield_operations(self, r): # comments show affected bits - bf = r.bitfield('a') - resp = (bf - .set('u8', 8, 255) # 00000000 11111111 - .get('u8', 0) # 00000000 - .get('u4', 8) # 1111 - .get('u4', 12) # 1111 - .get('u4', 13) # 111 0 - .execute()) + bf = r.bitfield("a") + resp = ( + bf.set("u8", 8, 255) # 00000000 11111111 + .get("u8", 0) # 00000000 + .get("u4", 8) # 1111 + .get("u4", 12) # 1111 + .get("u4", 13) # 111 0 + .execute() + ) assert resp == [0, 0, 15, 15, 14] # .set() returns the previous value... - resp = (bf - .set('u8', 4, 1) # 0000 0001 - .get('u16', 0) # 00000000 00011111 - .set('u16', 0, 0) # 00000000 00000000 - .execute()) + resp = ( + bf.set("u8", 4, 1) # 0000 0001 + .get("u16", 0) # 00000000 00011111 + .set("u16", 0, 0) # 00000000 00000000 + .execute() + ) assert resp == [15, 31, 31] # incrby adds to the value - resp = (bf - .incrby('u8', 8, 254) # 00000000 11111110 - .incrby('u8', 8, 1) # 00000000 11111111 - .get('u16', 0) # 00000000 11111111 - .execute()) + resp = ( + bf.incrby("u8", 8, 254) # 00000000 11111110 + .incrby("u8", 8, 1) # 00000000 11111111 + .get("u16", 0) # 00000000 11111111 + .execute() + ) assert resp == [254, 255, 255] # Verify overflow protection works as a method: - r.delete('a') - resp = (bf - .set('u8', 8, 254) # 00000000 11111110 - .overflow('fail') - .incrby('u8', 8, 2) # incrby 2 would overflow, None returned - .incrby('u8', 8, 1) # 00000000 11111111 - .incrby('u8', 8, 1) # incrby 1 would overflow, None returned - .get('u16', 0) # 00000000 11111111 - .execute()) + r.delete("a") + resp = ( + bf.set("u8", 8, 254) # 00000000 11111110 + .overflow("fail") + .incrby("u8", 8, 2) # incrby 2 would overflow, None returned + .incrby("u8", 8, 1) # 00000000 11111111 + .incrby("u8", 8, 1) # incrby 1 would overflow, None returned + .get("u16", 0) # 00000000 11111111 + .execute() + ) assert resp == [0, None, 255, None, 255] # Verify overflow protection works as arg to incrby: - r.delete('a') - resp = (bf - .set('u8', 8, 255) # 00000000 11111111 - .incrby('u8', 8, 1) # 00000000 00000000 wrap default - .set('u8', 8, 255) # 00000000 11111111 - .incrby('u8', 8, 1, 'FAIL') # 00000000 11111111 fail - .incrby('u8', 8, 1) # 00000000 11111111 still fail - .get('u16', 0) # 00000000 11111111 - .execute()) + r.delete("a") + resp = ( + bf.set("u8", 8, 255) # 00000000 11111111 + .incrby("u8", 8, 1) # 00000000 00000000 wrap default + .set("u8", 8, 255) # 00000000 11111111 + .incrby("u8", 8, 1, "FAIL") # 00000000 11111111 fail + .incrby("u8", 8, 1) # 00000000 11111111 still fail + .get("u16", 0) # 00000000 11111111 + .execute() + ) assert resp == [0, 0, 0, None, None, 255] # test default default_overflow - r.delete('a') - bf = r.bitfield('a', default_overflow='FAIL') - resp = (bf - .set('u8', 8, 255) # 00000000 11111111 - .incrby('u8', 8, 1) # 00000000 11111111 fail default - .get('u16', 0) # 00000000 11111111 - .execute()) + r.delete("a") + bf = r.bitfield("a", default_overflow="FAIL") + resp = ( + bf.set("u8", 8, 255) # 00000000 11111111 + .incrby("u8", 8, 1) # 00000000 11111111 fail default + .get("u16", 0) # 00000000 11111111 + .execute() + ) assert resp == [0, None, 255] - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_help(self, r): with pytest.raises(NotImplementedError): r.memory_help() - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_doctor(self, r): with pytest.raises(NotImplementedError): r.memory_doctor() - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_malloc_stats(self, r): if skip_if_redis_enterprise(None).args[0] is True: with pytest.raises(redis.exceptions.ResponseError): @@ -3756,11 +4029,11 @@ def test_memory_malloc_stats(self, r): assert r.memory_malloc_stats() - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_stats(self, r): # put a key into the current db to make sure that "db." # has data - r.set('foo', 'bar') + r.set("foo", "bar") if skip_if_redis_enterprise(None).args[0] is True: with pytest.raises(redis.exceptions.ResponseError): @@ -3770,104 +4043,113 @@ def test_memory_stats(self, r): stats = r.memory_stats() assert isinstance(stats, dict) for key, value in stats.items(): - if key.startswith('db.'): + if key.startswith("db."): assert isinstance(value, dict) - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") def test_memory_usage(self, r): - r.set('foo', 'bar') - assert isinstance(r.memory_usage('foo'), int) + r.set("foo", "bar") + assert isinstance(r.memory_usage("foo"), int) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") @skip_if_redis_enterprise def test_module_list(self, r): assert isinstance(r.module_list(), list) for x in r.module_list(): assert isinstance(x, dict) - @skip_if_server_version_lt('2.8.13') + @skip_if_server_version_lt("2.8.13") def test_command_count(self, r): res = r.command_count() assert isinstance(res, int) assert res >= 100 @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.13') + @skip_if_server_version_lt("2.8.13") def test_command_getkeys(self, r): - res = r.command_getkeys('MSET', 'a', 'b', 'c', 'd', 'e', 'f') - assert res == ['a', 'c', 'e'] - res = r.command_getkeys('EVAL', '"not consulted"', - '3', 'key1', 'key2', 'key3', - 'arg1', 'arg2', 'arg3', 'argN') - assert res == ['key1', 'key2', 'key3'] - - @skip_if_server_version_lt('2.8.13') + res = r.command_getkeys("MSET", "a", "b", "c", "d", "e", "f") + assert res == ["a", "c", "e"] + res = r.command_getkeys( + "EVAL", + '"not consulted"', + "3", + "key1", + "key2", + "key3", + "arg1", + "arg2", + "arg3", + "argN", + ) + assert res == ["key1", "key2", "key3"] + + @skip_if_server_version_lt("2.8.13") def test_command(self, r): res = r.command() assert len(res) >= 100 cmds = list(res.keys()) - assert 'set' in cmds - assert 'get' in cmds + assert "set" in cmds + assert "get" in cmds @pytest.mark.onlynoncluster - @skip_if_server_version_lt('4.0.0') + @skip_if_server_version_lt("4.0.0") @skip_if_redis_enterprise def test_module(self, r): with pytest.raises(redis.exceptions.ModuleError) as excinfo: - r.module_load('/some/fake/path') + r.module_load("/some/fake/path") assert "Error loading the extension." in str(excinfo.value) with pytest.raises(redis.exceptions.ModuleError) as excinfo: - r.module_load('/some/fake/path', 'arg1', 'arg2', 'arg3', 'arg4') + r.module_load("/some/fake/path", "arg1", "arg2", "arg3", "arg4") assert "Error loading the extension." in str(excinfo.value) - @skip_if_server_version_lt('2.6.0') + @skip_if_server_version_lt("2.6.0") def test_restore(self, r): # standard restore - key = 'foo' - r.set(key, 'bar') + key = "foo" + r.set(key, "bar") dumpdata = r.dump(key) r.delete(key) assert r.restore(key, 0, dumpdata) - assert r.get(key) == b'bar' + assert r.get(key) == b"bar" # overwrite restore with pytest.raises(redis.exceptions.ResponseError): assert r.restore(key, 0, dumpdata) - r.set(key, 'a new value!') + r.set(key, "a new value!") assert r.restore(key, 0, dumpdata, replace=True) - assert r.get(key) == b'bar' + assert r.get(key) == b"bar" # ttl check - key2 = 'another' - r.set(key2, 'blee!') + key2 = "another" + r.set(key2, "blee!") dumpdata = r.dump(key2) r.delete(key2) assert r.restore(key2, 0, dumpdata) assert r.ttl(key2) == -1 - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_restore_idletime(self, r): - key = 'yayakey' - r.set(key, 'blee!') + key = "yayakey" + r.set(key, "blee!") dumpdata = r.dump(key) r.delete(key) assert r.restore(key, 0, dumpdata, idletime=5) - assert r.get(key) == b'blee!' + assert r.get(key) == b"blee!" - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") def test_restore_frequency(self, r): - key = 'yayakey' - r.set(key, 'blee!') + key = "yayakey" + r.set(key, "blee!") dumpdata = r.dump(key) r.delete(key) assert r.restore(key, 0, dumpdata, frequency=5) - assert r.get(key) == b'blee!' + assert r.get(key) == b"blee!" @pytest.mark.onlynoncluster - @skip_if_server_version_lt('5.0.0') + @skip_if_server_version_lt("5.0.0") @skip_if_redis_enterprise def test_replicaof(self, r): with pytest.raises(redis.ResponseError): @@ -3877,36 +4159,38 @@ def test_replicaof(self, r): @pytest.mark.onlynoncluster class TestBinarySave: - def test_binary_get_set(self, r): - assert r.set(' foo bar ', '123') - assert r.get(' foo bar ') == b'123' + assert r.set(" foo bar ", "123") + assert r.get(" foo bar ") == b"123" - assert r.set(' foo\r\nbar\r\n ', '456') - assert r.get(' foo\r\nbar\r\n ') == b'456' + assert r.set(" foo\r\nbar\r\n ", "456") + assert r.get(" foo\r\nbar\r\n ") == b"456" - assert r.set(' \r\n\t\x07\x13 ', '789') - assert r.get(' \r\n\t\x07\x13 ') == b'789' + assert r.set(" \r\n\t\x07\x13 ", "789") + assert r.get(" \r\n\t\x07\x13 ") == b"789" - assert sorted(r.keys('*')) == \ - [b' \r\n\t\x07\x13 ', b' foo\r\nbar\r\n ', b' foo bar '] + assert sorted(r.keys("*")) == [ + b" \r\n\t\x07\x13 ", + b" foo\r\nbar\r\n ", + b" foo bar ", + ] - assert r.delete(' foo bar ') - assert r.delete(' foo\r\nbar\r\n ') - assert r.delete(' \r\n\t\x07\x13 ') + assert r.delete(" foo bar ") + assert r.delete(" foo\r\nbar\r\n ") + assert r.delete(" \r\n\t\x07\x13 ") def test_binary_lists(self, r): mapping = { - b'foo bar': [b'1', b'2', b'3'], - b'foo\r\nbar\r\n': [b'4', b'5', b'6'], - b'foo\tbar\x07': [b'7', b'8', b'9'], + b"foo bar": [b"1", b"2", b"3"], + b"foo\r\nbar\r\n": [b"4", b"5", b"6"], + b"foo\tbar\x07": [b"7", b"8", b"9"], } # fill in lists for key, value in mapping.items(): r.rpush(key, *value) # check that KEYS returns all the keys as they are - assert sorted(r.keys('*')) == sorted(mapping.keys()) + assert sorted(r.keys("*")) == sorted(mapping.keys()) # check that it is possible to get list content by key name for key, value in mapping.items(): @@ -3917,42 +4201,44 @@ def test_22_info(self, r): Older Redis versions contained 'allocation_stats' in INFO that was the cause of a number of bugs when parsing. """ - info = "allocation_stats:6=1,7=1,8=7141,9=180,10=92,11=116,12=5330," \ - "13=123,14=3091,15=11048,16=225842,17=1784,18=814,19=12020," \ - "20=2530,21=645,22=15113,23=8695,24=142860,25=318,26=3303," \ - "27=20561,28=54042,29=37390,30=1884,31=18071,32=31367,33=160," \ - "34=169,35=201,36=10155,37=1045,38=15078,39=22985,40=12523," \ - "41=15588,42=265,43=1287,44=142,45=382,46=945,47=426,48=171," \ - "49=56,50=516,51=43,52=41,53=46,54=54,55=75,56=647,57=332," \ - "58=32,59=39,60=48,61=35,62=62,63=32,64=221,65=26,66=30," \ - "67=36,68=41,69=44,70=26,71=144,72=169,73=24,74=37,75=25," \ - "76=42,77=21,78=126,79=374,80=27,81=40,82=43,83=47,84=46," \ - "85=114,86=34,87=37,88=7240,89=34,90=38,91=18,92=99,93=20," \ - "94=18,95=17,96=15,97=22,98=18,99=69,100=17,101=22,102=15," \ - "103=29,104=39,105=30,106=70,107=22,108=21,109=26,110=52," \ - "111=45,112=33,113=67,114=41,115=44,116=48,117=53,118=54," \ - "119=51,120=75,121=44,122=57,123=44,124=66,125=56,126=52," \ - "127=81,128=108,129=70,130=50,131=51,132=53,133=45,134=62," \ - "135=12,136=13,137=7,138=15,139=21,140=11,141=20,142=6,143=7," \ - "144=11,145=6,146=16,147=19,148=1112,149=1,151=83,154=1," \ - "155=1,156=1,157=1,160=1,161=1,162=2,166=1,169=1,170=1,171=2," \ - "172=1,174=1,176=2,177=9,178=34,179=73,180=30,181=1,185=3," \ - "187=1,188=1,189=1,192=1,196=1,198=1,200=1,201=1,204=1,205=1," \ - "207=1,208=1,209=1,214=2,215=31,216=78,217=28,218=5,219=2," \ - "220=1,222=1,225=1,227=1,234=1,242=1,250=1,252=1,253=1," \ - ">=256=203" + info = ( + "allocation_stats:6=1,7=1,8=7141,9=180,10=92,11=116,12=5330," + "13=123,14=3091,15=11048,16=225842,17=1784,18=814,19=12020," + "20=2530,21=645,22=15113,23=8695,24=142860,25=318,26=3303," + "27=20561,28=54042,29=37390,30=1884,31=18071,32=31367,33=160," + "34=169,35=201,36=10155,37=1045,38=15078,39=22985,40=12523," + "41=15588,42=265,43=1287,44=142,45=382,46=945,47=426,48=171," + "49=56,50=516,51=43,52=41,53=46,54=54,55=75,56=647,57=332," + "58=32,59=39,60=48,61=35,62=62,63=32,64=221,65=26,66=30," + "67=36,68=41,69=44,70=26,71=144,72=169,73=24,74=37,75=25," + "76=42,77=21,78=126,79=374,80=27,81=40,82=43,83=47,84=46," + "85=114,86=34,87=37,88=7240,89=34,90=38,91=18,92=99,93=20," + "94=18,95=17,96=15,97=22,98=18,99=69,100=17,101=22,102=15," + "103=29,104=39,105=30,106=70,107=22,108=21,109=26,110=52," + "111=45,112=33,113=67,114=41,115=44,116=48,117=53,118=54," + "119=51,120=75,121=44,122=57,123=44,124=66,125=56,126=52," + "127=81,128=108,129=70,130=50,131=51,132=53,133=45,134=62," + "135=12,136=13,137=7,138=15,139=21,140=11,141=20,142=6,143=7," + "144=11,145=6,146=16,147=19,148=1112,149=1,151=83,154=1," + "155=1,156=1,157=1,160=1,161=1,162=2,166=1,169=1,170=1,171=2," + "172=1,174=1,176=2,177=9,178=34,179=73,180=30,181=1,185=3," + "187=1,188=1,189=1,192=1,196=1,198=1,200=1,201=1,204=1,205=1," + "207=1,208=1,209=1,214=2,215=31,216=78,217=28,218=5,219=2," + "220=1,222=1,225=1,227=1,234=1,242=1,250=1,252=1,253=1," + ">=256=203" + ) parsed = parse_info(info) - assert 'allocation_stats' in parsed - assert '6' in parsed['allocation_stats'] - assert '>=256' in parsed['allocation_stats'] + assert "allocation_stats" in parsed + assert "6" in parsed["allocation_stats"] + assert ">=256" in parsed["allocation_stats"] @skip_if_redis_enterprise def test_large_responses(self, r): "The PythonParser has some special cases for return values > 1MB" # load up 5MB of data into a key - data = ''.join([ascii_letters] * (5000000 // len(ascii_letters))) - r['a'] = data - assert r['a'] == data.encode() + data = "".join([ascii_letters] * (5000000 // len(ascii_letters))) + r["a"] = data + assert r["a"] == data.encode() def test_floating_point_encoding(self, r): """ @@ -3960,5 +4246,5 @@ def test_floating_point_encoding(self, r): precision. """ timestamp = 1349673917.939762 - r.zadd('a', {'a1': timestamp}) - assert r.zscore('a', 'a1') == timestamp + r.zadd("a", {"a1": timestamp}) + assert r.zscore("a", "a1") == timestamp diff --git a/tests/test_connection.py b/tests/test_connection.py index 0071acab5c..22f1b718de 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,37 +1,40 @@ -from unittest import mock import types +from unittest import mock + import pytest from redis.exceptions import InvalidResponse from redis.utils import HIREDIS_AVAILABLE + from .conftest import skip_if_server_version_lt -@pytest.mark.skipif(HIREDIS_AVAILABLE, reason='PythonParser only') +@pytest.mark.skipif(HIREDIS_AVAILABLE, reason="PythonParser only") @pytest.mark.onlynoncluster def test_invalid_response(r): - raw = b'x' + raw = b"x" parser = r.connection._parser - with mock.patch.object(parser._buffer, 'readline', return_value=raw): + with mock.patch.object(parser._buffer, "readline", return_value=raw): with pytest.raises(InvalidResponse) as cm: parser.read_response() - assert str(cm.value) == f'Protocol Error: {raw!r}' + assert str(cm.value) == f"Protocol Error: {raw!r}" -@skip_if_server_version_lt('4.0.0') +@skip_if_server_version_lt("4.0.0") @pytest.mark.redismod def test_loading_external_modules(modclient): def inner(): pass - modclient.load_external_module('myfuncname', inner) - assert getattr(modclient, 'myfuncname') == inner - assert isinstance(getattr(modclient, 'myfuncname'), types.FunctionType) + modclient.load_external_module("myfuncname", inner) + assert getattr(modclient, "myfuncname") == inner + assert isinstance(getattr(modclient, "myfuncname"), types.FunctionType) # and call it from redis.commands import RedisModuleCommands + j = RedisModuleCommands.json - modclient.load_external_module('sometestfuncname', j) + modclient.load_external_module("sometestfuncname", j) # d = {'hello': 'world!'} # mod = j(modclient) diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 288d43dfd7..2602af82e1 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -1,17 +1,15 @@ import os -import pytest import re -import redis import time +from threading import Thread from unittest import mock -from threading import Thread +import pytest + +import redis from redis.connection import ssl_available, to_bool -from .conftest import ( - skip_if_server_version_lt, - skip_if_redis_enterprise, - _get_client -) + +from .conftest import _get_client, skip_if_redis_enterprise, skip_if_server_version_lt from .test_pubsub import wait_for_message @@ -30,107 +28,122 @@ def can_read(self): class TestConnectionPool: - def get_pool(self, connection_kwargs=None, max_connections=None, - connection_class=redis.Connection): + def get_pool( + self, + connection_kwargs=None, + max_connections=None, + connection_class=redis.Connection, + ): connection_kwargs = connection_kwargs or {} pool = redis.ConnectionPool( connection_class=connection_class, max_connections=max_connections, - **connection_kwargs) + **connection_kwargs, + ) return pool def test_connection_creation(self): - connection_kwargs = {'foo': 'bar', 'biz': 'baz'} - pool = self.get_pool(connection_kwargs=connection_kwargs, - connection_class=DummyConnection) - connection = pool.get_connection('_') + connection_kwargs = {"foo": "bar", "biz": "baz"} + pool = self.get_pool( + connection_kwargs=connection_kwargs, connection_class=DummyConnection + ) + connection = pool.get_connection("_") assert isinstance(connection, DummyConnection) assert connection.kwargs == connection_kwargs def test_multiple_connections(self, master_host): - connection_kwargs = {'host': master_host[0], 'port': master_host[1]} + connection_kwargs = {"host": master_host[0], "port": master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) - c1 = pool.get_connection('_') - c2 = pool.get_connection('_') + c1 = pool.get_connection("_") + c2 = pool.get_connection("_") assert c1 != c2 def test_max_connections(self, master_host): - connection_kwargs = {'host': master_host[0], 'port': master_host[1]} - pool = self.get_pool(max_connections=2, - connection_kwargs=connection_kwargs) - pool.get_connection('_') - pool.get_connection('_') + connection_kwargs = {"host": master_host[0], "port": master_host[1]} + pool = self.get_pool(max_connections=2, connection_kwargs=connection_kwargs) + pool.get_connection("_") + pool.get_connection("_") with pytest.raises(redis.ConnectionError): - pool.get_connection('_') + pool.get_connection("_") def test_reuse_previously_released_connection(self, master_host): - connection_kwargs = {'host': master_host[0], 'port': master_host[1]} + connection_kwargs = {"host": master_host[0], "port": master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) - c1 = pool.get_connection('_') + c1 = pool.get_connection("_") pool.release(c1) - c2 = pool.get_connection('_') + c2 = pool.get_connection("_") assert c1 == c2 def test_repr_contains_db_info_tcp(self): connection_kwargs = { - 'host': 'localhost', - 'port': 6379, - 'db': 1, - 'client_name': 'test-client' + "host": "localhost", + "port": 6379, + "db": 1, + "client_name": "test-client", } - pool = self.get_pool(connection_kwargs=connection_kwargs, - connection_class=redis.Connection) - expected = ('ConnectionPool>') + pool = self.get_pool( + connection_kwargs=connection_kwargs, connection_class=redis.Connection + ) + expected = ( + "ConnectionPool>" + ) assert repr(pool) == expected def test_repr_contains_db_info_unix(self): - connection_kwargs = { - 'path': '/abc', - 'db': 1, - 'client_name': 'test-client' - } - pool = self.get_pool(connection_kwargs=connection_kwargs, - connection_class=redis.UnixDomainSocketConnection) - expected = ('ConnectionPool>') + connection_kwargs = {"path": "/abc", "db": 1, "client_name": "test-client"} + pool = self.get_pool( + connection_kwargs=connection_kwargs, + connection_class=redis.UnixDomainSocketConnection, + ) + expected = ( + "ConnectionPool>" + ) assert repr(pool) == expected class TestBlockingConnectionPool: def get_pool(self, connection_kwargs=None, max_connections=10, timeout=20): connection_kwargs = connection_kwargs or {} - pool = redis.BlockingConnectionPool(connection_class=DummyConnection, - max_connections=max_connections, - timeout=timeout, - **connection_kwargs) + pool = redis.BlockingConnectionPool( + connection_class=DummyConnection, + max_connections=max_connections, + timeout=timeout, + **connection_kwargs, + ) return pool def test_connection_creation(self, master_host): - connection_kwargs = {'foo': 'bar', 'biz': 'baz', - 'host': master_host[0], 'port': master_host[1]} + connection_kwargs = { + "foo": "bar", + "biz": "baz", + "host": master_host[0], + "port": master_host[1], + } pool = self.get_pool(connection_kwargs=connection_kwargs) - connection = pool.get_connection('_') + connection = pool.get_connection("_") assert isinstance(connection, DummyConnection) assert connection.kwargs == connection_kwargs def test_multiple_connections(self, master_host): - connection_kwargs = {'host': master_host[0], 'port': master_host[1]} + connection_kwargs = {"host": master_host[0], "port": master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) - c1 = pool.get_connection('_') - c2 = pool.get_connection('_') + c1 = pool.get_connection("_") + c2 = pool.get_connection("_") assert c1 != c2 def test_connection_pool_blocks_until_timeout(self, master_host): "When out of connections, block for timeout seconds, then raise" - connection_kwargs = {'host': master_host[0], 'port': master_host[1]} - pool = self.get_pool(max_connections=1, timeout=0.1, - connection_kwargs=connection_kwargs) - pool.get_connection('_') + connection_kwargs = {"host": master_host[0], "port": master_host[1]} + pool = self.get_pool( + max_connections=1, timeout=0.1, connection_kwargs=connection_kwargs + ) + pool.get_connection("_") start = time.time() with pytest.raises(redis.ConnectionError): - pool.get_connection('_') + pool.get_connection("_") # we should have waited at least 0.1 seconds assert time.time() - start >= 0.1 @@ -139,10 +152,11 @@ def test_connection_pool_blocks_until_conn_available(self, master_host): When out of connections, block until another connection is released to the pool """ - connection_kwargs = {'host': master_host[0], 'port': master_host[1]} - pool = self.get_pool(max_connections=1, timeout=2, - connection_kwargs=connection_kwargs) - c1 = pool.get_connection('_') + connection_kwargs = {"host": master_host[0], "port": master_host[1]} + pool = self.get_pool( + max_connections=1, timeout=2, connection_kwargs=connection_kwargs + ) + c1 = pool.get_connection("_") def target(): time.sleep(0.1) @@ -150,294 +164,295 @@ def target(): start = time.time() Thread(target=target).start() - pool.get_connection('_') + pool.get_connection("_") assert time.time() - start >= 0.1 def test_reuse_previously_released_connection(self, master_host): - connection_kwargs = {'host': master_host[0], 'port': master_host[1]} + connection_kwargs = {"host": master_host[0], "port": master_host[1]} pool = self.get_pool(connection_kwargs=connection_kwargs) - c1 = pool.get_connection('_') + c1 = pool.get_connection("_") pool.release(c1) - c2 = pool.get_connection('_') + c2 = pool.get_connection("_") assert c1 == c2 def test_repr_contains_db_info_tcp(self): pool = redis.ConnectionPool( - host='localhost', - port=6379, - client_name='test-client' + host="localhost", port=6379, client_name="test-client" + ) + expected = ( + "ConnectionPool>" ) - expected = ('ConnectionPool>') assert repr(pool) == expected def test_repr_contains_db_info_unix(self): pool = redis.ConnectionPool( connection_class=redis.UnixDomainSocketConnection, - path='abc', - client_name='test-client' + path="abc", + client_name="test-client", + ) + expected = ( + "ConnectionPool>" ) - expected = ('ConnectionPool>') assert repr(pool) == expected class TestConnectionPoolURLParsing: def test_hostname(self): - pool = redis.ConnectionPool.from_url('redis://my.host') + pool = redis.ConnectionPool.from_url("redis://my.host") assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'my.host', + "host": "my.host", } def test_quoted_hostname(self): - pool = redis.ConnectionPool.from_url('redis://my %2F host %2B%3D+') + pool = redis.ConnectionPool.from_url("redis://my %2F host %2B%3D+") assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'my / host +=+', + "host": "my / host +=+", } def test_port(self): - pool = redis.ConnectionPool.from_url('redis://localhost:6380') + pool = redis.ConnectionPool.from_url("redis://localhost:6380") assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'port': 6380, + "host": "localhost", + "port": 6380, } @skip_if_server_version_lt("6.0.0") def test_username(self): - pool = redis.ConnectionPool.from_url('redis://myuser:@localhost') + pool = redis.ConnectionPool.from_url("redis://myuser:@localhost") assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'username': 'myuser', + "host": "localhost", + "username": "myuser", } @skip_if_server_version_lt("6.0.0") def test_quoted_username(self): pool = redis.ConnectionPool.from_url( - 'redis://%2Fmyuser%2F%2B name%3D%24+:@localhost') + "redis://%2Fmyuser%2F%2B name%3D%24+:@localhost" + ) assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'username': '/myuser/+ name=$+', + "host": "localhost", + "username": "/myuser/+ name=$+", } def test_password(self): - pool = redis.ConnectionPool.from_url('redis://:mypassword@localhost') + pool = redis.ConnectionPool.from_url("redis://:mypassword@localhost") assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'password': 'mypassword', + "host": "localhost", + "password": "mypassword", } def test_quoted_password(self): pool = redis.ConnectionPool.from_url( - 'redis://:%2Fmypass%2F%2B word%3D%24+@localhost') + "redis://:%2Fmypass%2F%2B word%3D%24+@localhost" + ) assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'password': '/mypass/+ word=$+', + "host": "localhost", + "password": "/mypass/+ word=$+", } @skip_if_server_version_lt("6.0.0") def test_username_and_password(self): - pool = redis.ConnectionPool.from_url('redis://myuser:mypass@localhost') + pool = redis.ConnectionPool.from_url("redis://myuser:mypass@localhost") assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'username': 'myuser', - 'password': 'mypass', + "host": "localhost", + "username": "myuser", + "password": "mypass", } def test_db_as_argument(self): - pool = redis.ConnectionPool.from_url('redis://localhost', db=1) + pool = redis.ConnectionPool.from_url("redis://localhost", db=1) assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'db': 1, + "host": "localhost", + "db": 1, } def test_db_in_path(self): - pool = redis.ConnectionPool.from_url('redis://localhost/2', db=1) + pool = redis.ConnectionPool.from_url("redis://localhost/2", db=1) assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'db': 2, + "host": "localhost", + "db": 2, } def test_db_in_querystring(self): - pool = redis.ConnectionPool.from_url('redis://localhost/2?db=3', - db=1) + pool = redis.ConnectionPool.from_url("redis://localhost/2?db=3", db=1) assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'db': 3, + "host": "localhost", + "db": 3, } def test_extra_typed_querystring_options(self): pool = redis.ConnectionPool.from_url( - 'redis://localhost/2?socket_timeout=20&socket_connect_timeout=10' - '&socket_keepalive=&retry_on_timeout=Yes&max_connections=10' + "redis://localhost/2?socket_timeout=20&socket_connect_timeout=10" + "&socket_keepalive=&retry_on_timeout=Yes&max_connections=10" ) assert pool.connection_class == redis.Connection assert pool.connection_kwargs == { - 'host': 'localhost', - 'db': 2, - 'socket_timeout': 20.0, - 'socket_connect_timeout': 10.0, - 'retry_on_timeout': True, + "host": "localhost", + "db": 2, + "socket_timeout": 20.0, + "socket_connect_timeout": 10.0, + "retry_on_timeout": True, } assert pool.max_connections == 10 def test_boolean_parsing(self): for expected, value in ( - (None, None), - (None, ''), - (False, 0), (False, '0'), - (False, 'f'), (False, 'F'), (False, 'False'), - (False, 'n'), (False, 'N'), (False, 'No'), - (True, 1), (True, '1'), - (True, 'y'), (True, 'Y'), (True, 'Yes'), + (None, None), + (None, ""), + (False, 0), + (False, "0"), + (False, "f"), + (False, "F"), + (False, "False"), + (False, "n"), + (False, "N"), + (False, "No"), + (True, 1), + (True, "1"), + (True, "y"), + (True, "Y"), + (True, "Yes"), ): assert expected is to_bool(value) def test_client_name_in_querystring(self): - pool = redis.ConnectionPool.from_url( - 'redis://location?client_name=test-client' - ) - assert pool.connection_kwargs['client_name'] == 'test-client' + pool = redis.ConnectionPool.from_url("redis://location?client_name=test-client") + assert pool.connection_kwargs["client_name"] == "test-client" def test_invalid_extra_typed_querystring_options(self): with pytest.raises(ValueError): redis.ConnectionPool.from_url( - 'redis://localhost/2?socket_timeout=_&' - 'socket_connect_timeout=abc' + "redis://localhost/2?socket_timeout=_&" "socket_connect_timeout=abc" ) def test_extra_querystring_options(self): - pool = redis.ConnectionPool.from_url('redis://localhost?a=1&b=2') + pool = redis.ConnectionPool.from_url("redis://localhost?a=1&b=2") assert pool.connection_class == redis.Connection - assert pool.connection_kwargs == { - 'host': 'localhost', - 'a': '1', - 'b': '2' - } + assert pool.connection_kwargs == {"host": "localhost", "a": "1", "b": "2"} def test_calling_from_subclass_returns_correct_instance(self): - pool = redis.BlockingConnectionPool.from_url('redis://localhost') + pool = redis.BlockingConnectionPool.from_url("redis://localhost") assert isinstance(pool, redis.BlockingConnectionPool) def test_client_creates_connection_pool(self): - r = redis.Redis.from_url('redis://myhost') + r = redis.Redis.from_url("redis://myhost") assert r.connection_pool.connection_class == redis.Connection assert r.connection_pool.connection_kwargs == { - 'host': 'myhost', + "host": "myhost", } def test_invalid_scheme_raises_error(self): with pytest.raises(ValueError) as cm: - redis.ConnectionPool.from_url('localhost') + redis.ConnectionPool.from_url("localhost") assert str(cm.value) == ( - 'Redis URL must specify one of the following schemes ' - '(redis://, rediss://, unix://)' + "Redis URL must specify one of the following schemes " + "(redis://, rediss://, unix://)" ) class TestConnectionPoolUnixSocketURLParsing: def test_defaults(self): - pool = redis.ConnectionPool.from_url('unix:///socket') + pool = redis.ConnectionPool.from_url("unix:///socket") assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == { - 'path': '/socket', + "path": "/socket", } @skip_if_server_version_lt("6.0.0") def test_username(self): - pool = redis.ConnectionPool.from_url('unix://myuser:@/socket') + pool = redis.ConnectionPool.from_url("unix://myuser:@/socket") assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == { - 'path': '/socket', - 'username': 'myuser', + "path": "/socket", + "username": "myuser", } @skip_if_server_version_lt("6.0.0") def test_quoted_username(self): pool = redis.ConnectionPool.from_url( - 'unix://%2Fmyuser%2F%2B name%3D%24+:@/socket') + "unix://%2Fmyuser%2F%2B name%3D%24+:@/socket" + ) assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == { - 'path': '/socket', - 'username': '/myuser/+ name=$+', + "path": "/socket", + "username": "/myuser/+ name=$+", } def test_password(self): - pool = redis.ConnectionPool.from_url('unix://:mypassword@/socket') + pool = redis.ConnectionPool.from_url("unix://:mypassword@/socket") assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == { - 'path': '/socket', - 'password': 'mypassword', + "path": "/socket", + "password": "mypassword", } def test_quoted_password(self): pool = redis.ConnectionPool.from_url( - 'unix://:%2Fmypass%2F%2B word%3D%24+@/socket') + "unix://:%2Fmypass%2F%2B word%3D%24+@/socket" + ) assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == { - 'path': '/socket', - 'password': '/mypass/+ word=$+', + "path": "/socket", + "password": "/mypass/+ word=$+", } def test_quoted_path(self): pool = redis.ConnectionPool.from_url( - 'unix://:mypassword@/my%2Fpath%2Fto%2F..%2F+_%2B%3D%24ocket') + "unix://:mypassword@/my%2Fpath%2Fto%2F..%2F+_%2B%3D%24ocket" + ) assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == { - 'path': '/my/path/to/../+_+=$ocket', - 'password': 'mypassword', + "path": "/my/path/to/../+_+=$ocket", + "password": "mypassword", } def test_db_as_argument(self): - pool = redis.ConnectionPool.from_url('unix:///socket', db=1) + pool = redis.ConnectionPool.from_url("unix:///socket", db=1) assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == { - 'path': '/socket', - 'db': 1, + "path": "/socket", + "db": 1, } def test_db_in_querystring(self): - pool = redis.ConnectionPool.from_url('unix:///socket?db=2', db=1) + pool = redis.ConnectionPool.from_url("unix:///socket?db=2", db=1) assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == { - 'path': '/socket', - 'db': 2, + "path": "/socket", + "db": 2, } def test_client_name_in_querystring(self): - pool = redis.ConnectionPool.from_url( - 'redis://location?client_name=test-client' - ) - assert pool.connection_kwargs['client_name'] == 'test-client' + pool = redis.ConnectionPool.from_url("redis://location?client_name=test-client") + assert pool.connection_kwargs["client_name"] == "test-client" def test_extra_querystring_options(self): - pool = redis.ConnectionPool.from_url('unix:///socket?a=1&b=2') + pool = redis.ConnectionPool.from_url("unix:///socket?a=1&b=2") assert pool.connection_class == redis.UnixDomainSocketConnection - assert pool.connection_kwargs == { - 'path': '/socket', - 'a': '1', - 'b': '2' - } + assert pool.connection_kwargs == {"path": "/socket", "a": "1", "b": "2"} @pytest.mark.skipif(not ssl_available, reason="SSL not installed") class TestSSLConnectionURLParsing: def test_host(self): - pool = redis.ConnectionPool.from_url('rediss://my.host') + pool = redis.ConnectionPool.from_url("rediss://my.host") assert pool.connection_class == redis.SSLConnection assert pool.connection_kwargs == { - 'host': 'my.host', + "host": "my.host", } def test_cert_reqs_options(self): @@ -447,25 +462,20 @@ class DummyConnectionPool(redis.ConnectionPool): def get_connection(self, *args, **kwargs): return self.make_connection() - pool = DummyConnectionPool.from_url( - 'rediss://?ssl_cert_reqs=none') - assert pool.get_connection('_').cert_reqs == ssl.CERT_NONE + pool = DummyConnectionPool.from_url("rediss://?ssl_cert_reqs=none") + assert pool.get_connection("_").cert_reqs == ssl.CERT_NONE - pool = DummyConnectionPool.from_url( - 'rediss://?ssl_cert_reqs=optional') - assert pool.get_connection('_').cert_reqs == ssl.CERT_OPTIONAL + pool = DummyConnectionPool.from_url("rediss://?ssl_cert_reqs=optional") + assert pool.get_connection("_").cert_reqs == ssl.CERT_OPTIONAL - pool = DummyConnectionPool.from_url( - 'rediss://?ssl_cert_reqs=required') - assert pool.get_connection('_').cert_reqs == ssl.CERT_REQUIRED + pool = DummyConnectionPool.from_url("rediss://?ssl_cert_reqs=required") + assert pool.get_connection("_").cert_reqs == ssl.CERT_REQUIRED - pool = DummyConnectionPool.from_url( - 'rediss://?ssl_check_hostname=False') - assert pool.get_connection('_').check_hostname is False + pool = DummyConnectionPool.from_url("rediss://?ssl_check_hostname=False") + assert pool.get_connection("_").check_hostname is False - pool = DummyConnectionPool.from_url( - 'rediss://?ssl_check_hostname=True') - assert pool.get_connection('_').check_hostname is True + pool = DummyConnectionPool.from_url("rediss://?ssl_check_hostname=True") + assert pool.get_connection("_").check_hostname is True class TestConnection: @@ -485,7 +495,7 @@ def test_on_connect_error(self): assert not pool._available_connections[0]._sock @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.8') + @skip_if_server_version_lt("2.8.8") @skip_if_redis_enterprise def test_busy_loading_disconnects_socket(self, r): """ @@ -493,11 +503,11 @@ def test_busy_loading_disconnects_socket(self, r): disconnected and a BusyLoadingError raised """ with pytest.raises(redis.BusyLoadingError): - r.execute_command('DEBUG', 'ERROR', 'LOADING fake message') + r.execute_command("DEBUG", "ERROR", "LOADING fake message") assert not r.connection._sock @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.8') + @skip_if_server_version_lt("2.8.8") @skip_if_redis_enterprise def test_busy_loading_from_pipeline_immediate_command(self, r): """ @@ -506,15 +516,14 @@ def test_busy_loading_from_pipeline_immediate_command(self, r): """ pipe = r.pipeline() with pytest.raises(redis.BusyLoadingError): - pipe.immediate_execute_command('DEBUG', 'ERROR', - 'LOADING fake message') + pipe.immediate_execute_command("DEBUG", "ERROR", "LOADING fake message") pool = r.connection_pool assert not pipe.connection assert len(pool._available_connections) == 1 assert not pool._available_connections[0]._sock @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.8') + @skip_if_server_version_lt("2.8.8") @skip_if_redis_enterprise def test_busy_loading_from_pipeline(self, r): """ @@ -522,7 +531,7 @@ def test_busy_loading_from_pipeline(self, r): regardless of the raise_on_error flag. """ pipe = r.pipeline() - pipe.execute_command('DEBUG', 'ERROR', 'LOADING fake message') + pipe.execute_command("DEBUG", "ERROR", "LOADING fake message") with pytest.raises(redis.BusyLoadingError): pipe.execute() pool = r.connection_pool @@ -530,31 +539,31 @@ def test_busy_loading_from_pipeline(self, r): assert len(pool._available_connections) == 1 assert not pool._available_connections[0]._sock - @skip_if_server_version_lt('2.8.8') + @skip_if_server_version_lt("2.8.8") @skip_if_redis_enterprise def test_read_only_error(self, r): "READONLY errors get turned in ReadOnlyError exceptions" with pytest.raises(redis.ReadOnlyError): - r.execute_command('DEBUG', 'ERROR', 'READONLY blah blah') + r.execute_command("DEBUG", "ERROR", "READONLY blah blah") def test_connect_from_url_tcp(self): - connection = redis.Redis.from_url('redis://localhost') + connection = redis.Redis.from_url("redis://localhost") pool = connection.connection_pool - assert re.match('(.*)<(.*)<(.*)>>', repr(pool)).groups() == ( - 'ConnectionPool', - 'Connection', - 'host=localhost,port=6379,db=0', + assert re.match("(.*)<(.*)<(.*)>>", repr(pool)).groups() == ( + "ConnectionPool", + "Connection", + "host=localhost,port=6379,db=0", ) def test_connect_from_url_unix(self): - connection = redis.Redis.from_url('unix:///path/to/socket') + connection = redis.Redis.from_url("unix:///path/to/socket") pool = connection.connection_pool - assert re.match('(.*)<(.*)<(.*)>>', repr(pool)).groups() == ( - 'ConnectionPool', - 'UnixDomainSocketConnection', - 'path=/path/to/socket,db=0', + assert re.match("(.*)<(.*)<(.*)>>", repr(pool)).groups() == ( + "ConnectionPool", + "UnixDomainSocketConnection", + "path=/path/to/socket,db=0", ) @skip_if_redis_enterprise @@ -564,28 +573,27 @@ def test_connect_no_auth_supplied_when_required(self, r): password but one isn't supplied. """ with pytest.raises(redis.AuthenticationError): - r.execute_command('DEBUG', 'ERROR', - 'ERR Client sent AUTH, but no password is set') + r.execute_command( + "DEBUG", "ERROR", "ERR Client sent AUTH, but no password is set" + ) @skip_if_redis_enterprise def test_connect_invalid_password_supplied(self, r): "AuthenticationError should be raised when sending the wrong password" with pytest.raises(redis.AuthenticationError): - r.execute_command('DEBUG', 'ERROR', 'ERR invalid password') + r.execute_command("DEBUG", "ERROR", "ERR invalid password") @pytest.mark.onlynoncluster class TestMultiConnectionClient: @pytest.fixture() def r(self, request): - return _get_client(redis.Redis, - request, - single_connection_client=False) + return _get_client(redis.Redis, request, single_connection_client=False) def test_multi_connection_command(self, r): assert not r.connection - assert r.set('a', '123') - assert r.get('a') == b'123' + assert r.set("a", "123") + assert r.get("a") == b"123" @pytest.mark.onlynoncluster @@ -594,8 +602,7 @@ class TestHealthCheck: @pytest.fixture() def r(self, request): - return _get_client(redis.Redis, request, - health_check_interval=self.interval) + return _get_client(redis.Redis, request, health_check_interval=self.interval) def assert_interval_advanced(self, connection): diff = connection.next_health_check - time.time() @@ -608,61 +615,66 @@ def test_health_check_runs(self, r): def test_arbitrary_command_invokes_health_check(self, r): # invoke a command to make sure the connection is entirely setup - r.get('foo') + r.get("foo") r.connection.next_health_check = time.time() - with mock.patch.object(r.connection, 'send_command', - wraps=r.connection.send_command) as m: - r.get('foo') - m.assert_called_with('PING', check_health=False) + with mock.patch.object( + r.connection, "send_command", wraps=r.connection.send_command + ) as m: + r.get("foo") + m.assert_called_with("PING", check_health=False) self.assert_interval_advanced(r.connection) def test_arbitrary_command_advances_next_health_check(self, r): - r.get('foo') + r.get("foo") next_health_check = r.connection.next_health_check - r.get('foo') + r.get("foo") assert next_health_check < r.connection.next_health_check def test_health_check_not_invoked_within_interval(self, r): - r.get('foo') - with mock.patch.object(r.connection, 'send_command', - wraps=r.connection.send_command) as m: - r.get('foo') - ping_call_spec = (('PING',), {'check_health': False}) + r.get("foo") + with mock.patch.object( + r.connection, "send_command", wraps=r.connection.send_command + ) as m: + r.get("foo") + ping_call_spec = (("PING",), {"check_health": False}) assert ping_call_spec not in m.call_args_list def test_health_check_in_pipeline(self, r): with r.pipeline(transaction=False) as pipe: - pipe.connection = pipe.connection_pool.get_connection('_') + pipe.connection = pipe.connection_pool.get_connection("_") pipe.connection.next_health_check = 0 - with mock.patch.object(pipe.connection, 'send_command', - wraps=pipe.connection.send_command) as m: - responses = pipe.set('foo', 'bar').get('foo').execute() - m.assert_any_call('PING', check_health=False) - assert responses == [True, b'bar'] + with mock.patch.object( + pipe.connection, "send_command", wraps=pipe.connection.send_command + ) as m: + responses = pipe.set("foo", "bar").get("foo").execute() + m.assert_any_call("PING", check_health=False) + assert responses == [True, b"bar"] def test_health_check_in_transaction(self, r): with r.pipeline(transaction=True) as pipe: - pipe.connection = pipe.connection_pool.get_connection('_') + pipe.connection = pipe.connection_pool.get_connection("_") pipe.connection.next_health_check = 0 - with mock.patch.object(pipe.connection, 'send_command', - wraps=pipe.connection.send_command) as m: - responses = pipe.set('foo', 'bar').get('foo').execute() - m.assert_any_call('PING', check_health=False) - assert responses == [True, b'bar'] + with mock.patch.object( + pipe.connection, "send_command", wraps=pipe.connection.send_command + ) as m: + responses = pipe.set("foo", "bar").get("foo").execute() + m.assert_any_call("PING", check_health=False) + assert responses == [True, b"bar"] def test_health_check_in_watched_pipeline(self, r): - r.set('foo', 'bar') + r.set("foo", "bar") with r.pipeline(transaction=False) as pipe: - pipe.connection = pipe.connection_pool.get_connection('_') + pipe.connection = pipe.connection_pool.get_connection("_") pipe.connection.next_health_check = 0 - with mock.patch.object(pipe.connection, 'send_command', - wraps=pipe.connection.send_command) as m: - pipe.watch('foo') + with mock.patch.object( + pipe.connection, "send_command", wraps=pipe.connection.send_command + ) as m: + pipe.watch("foo") # the health check should be called when watching - m.assert_called_with('PING', check_health=False) + m.assert_called_with("PING", check_health=False) self.assert_interval_advanced(pipe.connection) - assert pipe.get('foo') == b'bar' + assert pipe.get("foo") == b"bar" # reset the mock to clear the call list and schedule another # health check @@ -670,27 +682,28 @@ def test_health_check_in_watched_pipeline(self, r): pipe.connection.next_health_check = 0 pipe.multi() - responses = pipe.set('foo', 'not-bar').get('foo').execute() - assert responses == [True, b'not-bar'] - m.assert_any_call('PING', check_health=False) + responses = pipe.set("foo", "not-bar").get("foo").execute() + assert responses == [True, b"not-bar"] + m.assert_any_call("PING", check_health=False) def test_health_check_in_pubsub_before_subscribe(self, r): "A health check happens before the first [p]subscribe" p = r.pubsub() - p.connection = p.connection_pool.get_connection('_') + p.connection = p.connection_pool.get_connection("_") p.connection.next_health_check = 0 - with mock.patch.object(p.connection, 'send_command', - wraps=p.connection.send_command) as m: + with mock.patch.object( + p.connection, "send_command", wraps=p.connection.send_command + ) as m: assert not p.subscribed - p.subscribe('foo') + p.subscribe("foo") # the connection is not yet in pubsub mode, so the normal # ping/pong within connection.send_command should check # the health of the connection - m.assert_any_call('PING', check_health=False) + m.assert_any_call("PING", check_health=False) self.assert_interval_advanced(p.connection) subscribe_message = wait_for_message(p) - assert subscribe_message['type'] == 'subscribe' + assert subscribe_message["type"] == "subscribe" def test_health_check_in_pubsub_after_subscribed(self, r): """ @@ -698,38 +711,38 @@ def test_health_check_in_pubsub_after_subscribed(self, r): connection health """ p = r.pubsub() - p.connection = p.connection_pool.get_connection('_') + p.connection = p.connection_pool.get_connection("_") p.connection.next_health_check = 0 - with mock.patch.object(p.connection, 'send_command', - wraps=p.connection.send_command) as m: - p.subscribe('foo') + with mock.patch.object( + p.connection, "send_command", wraps=p.connection.send_command + ) as m: + p.subscribe("foo") subscribe_message = wait_for_message(p) - assert subscribe_message['type'] == 'subscribe' + assert subscribe_message["type"] == "subscribe" self.assert_interval_advanced(p.connection) # because we weren't subscribed when sending the subscribe # message to 'foo', the connection's standard check_health ran # prior to subscribing. - m.assert_any_call('PING', check_health=False) + m.assert_any_call("PING", check_health=False) p.connection.next_health_check = 0 m.reset_mock() - p.subscribe('bar') + p.subscribe("bar") # the second subscribe issues exactly only command (the subscribe) # and the health check is not invoked - m.assert_called_once_with('SUBSCRIBE', 'bar', check_health=False) + m.assert_called_once_with("SUBSCRIBE", "bar", check_health=False) # since no message has been read since the health check was # reset, it should still be 0 assert p.connection.next_health_check == 0 subscribe_message = wait_for_message(p) - assert subscribe_message['type'] == 'subscribe' + assert subscribe_message["type"] == "subscribe" assert wait_for_message(p) is None # now that the connection is subscribed, the pubsub health # check should have taken over and include the HEALTH_CHECK_MESSAGE - m.assert_any_call('PING', p.HEALTH_CHECK_MESSAGE, - check_health=False) + m.assert_any_call("PING", p.HEALTH_CHECK_MESSAGE, check_health=False) self.assert_interval_advanced(p.connection) def test_health_check_in_pubsub_poll(self, r): @@ -738,12 +751,13 @@ def test_health_check_in_pubsub_poll(self, r): check the connection's health. """ p = r.pubsub() - p.connection = p.connection_pool.get_connection('_') - with mock.patch.object(p.connection, 'send_command', - wraps=p.connection.send_command) as m: - p.subscribe('foo') + p.connection = p.connection_pool.get_connection("_") + with mock.patch.object( + p.connection, "send_command", wraps=p.connection.send_command + ) as m: + p.subscribe("foo") subscribe_message = wait_for_message(p) - assert subscribe_message['type'] == 'subscribe' + assert subscribe_message["type"] == "subscribe" self.assert_interval_advanced(p.connection) # polling the connection before the health check interval @@ -759,6 +773,5 @@ def test_health_check_in_pubsub_poll(self, r): # should be advanced p.connection.next_health_check = 0 assert wait_for_message(p) is None - m.assert_called_with('PING', p.HEALTH_CHECK_MESSAGE, - check_health=False) + m.assert_called_with("PING", p.HEALTH_CHECK_MESSAGE, check_health=False) self.assert_interval_advanced(p.connection) diff --git a/tests/test_encoding.py b/tests/test_encoding.py index 706654f89f..bd0f09fcc0 100644 --- a/tests/test_encoding.py +++ b/tests/test_encoding.py @@ -1,7 +1,8 @@ import pytest -import redis +import redis from redis.connection import Connection + from .conftest import _get_client @@ -19,62 +20,70 @@ def r_no_decode(self, request): ) def test_simple_encoding(self, r_no_decode): - unicode_string = chr(3456) + 'abcd' + chr(3421) - r_no_decode['unicode-string'] = unicode_string.encode('utf-8') - cached_val = r_no_decode['unicode-string'] + unicode_string = chr(3456) + "abcd" + chr(3421) + r_no_decode["unicode-string"] = unicode_string.encode("utf-8") + cached_val = r_no_decode["unicode-string"] assert isinstance(cached_val, bytes) - assert unicode_string == cached_val.decode('utf-8') + assert unicode_string == cached_val.decode("utf-8") def test_simple_encoding_and_decoding(self, r): - unicode_string = chr(3456) + 'abcd' + chr(3421) - r['unicode-string'] = unicode_string - cached_val = r['unicode-string'] + unicode_string = chr(3456) + "abcd" + chr(3421) + r["unicode-string"] = unicode_string + cached_val = r["unicode-string"] assert isinstance(cached_val, str) assert unicode_string == cached_val def test_memoryview_encoding(self, r_no_decode): - unicode_string = chr(3456) + 'abcd' + chr(3421) - unicode_string_view = memoryview(unicode_string.encode('utf-8')) - r_no_decode['unicode-string-memoryview'] = unicode_string_view - cached_val = r_no_decode['unicode-string-memoryview'] + unicode_string = chr(3456) + "abcd" + chr(3421) + unicode_string_view = memoryview(unicode_string.encode("utf-8")) + r_no_decode["unicode-string-memoryview"] = unicode_string_view + cached_val = r_no_decode["unicode-string-memoryview"] # The cached value won't be a memoryview because it's a copy from Redis assert isinstance(cached_val, bytes) - assert unicode_string == cached_val.decode('utf-8') + assert unicode_string == cached_val.decode("utf-8") def test_memoryview_encoding_and_decoding(self, r): - unicode_string = chr(3456) + 'abcd' + chr(3421) - unicode_string_view = memoryview(unicode_string.encode('utf-8')) - r['unicode-string-memoryview'] = unicode_string_view - cached_val = r['unicode-string-memoryview'] + unicode_string = chr(3456) + "abcd" + chr(3421) + unicode_string_view = memoryview(unicode_string.encode("utf-8")) + r["unicode-string-memoryview"] = unicode_string_view + cached_val = r["unicode-string-memoryview"] assert isinstance(cached_val, str) assert unicode_string == cached_val def test_list_encoding(self, r): - unicode_string = chr(3456) + 'abcd' + chr(3421) + unicode_string = chr(3456) + "abcd" + chr(3421) result = [unicode_string, unicode_string, unicode_string] - r.rpush('a', *result) - assert r.lrange('a', 0, -1) == result + r.rpush("a", *result) + assert r.lrange("a", 0, -1) == result class TestEncodingErrors: def test_ignore(self, request): - r = _get_client(redis.Redis, request=request, decode_responses=True, - encoding_errors='ignore') - r.set('a', b'foo\xff') - assert r.get('a') == 'foo' + r = _get_client( + redis.Redis, + request=request, + decode_responses=True, + encoding_errors="ignore", + ) + r.set("a", b"foo\xff") + assert r.get("a") == "foo" def test_replace(self, request): - r = _get_client(redis.Redis, request=request, decode_responses=True, - encoding_errors='replace') - r.set('a', b'foo\xff') - assert r.get('a') == 'foo\ufffd' + r = _get_client( + redis.Redis, + request=request, + decode_responses=True, + encoding_errors="replace", + ) + r.set("a", b"foo\xff") + assert r.get("a") == "foo\ufffd" class TestMemoryviewsAreNotPacked: def test_memoryviews_are_not_packed(self): c = Connection() - arg = memoryview(b'some_arg') - arg_list = ['SOME_COMMAND', arg] + arg = memoryview(b"some_arg") + arg_list = ["SOME_COMMAND", arg] cmd = c.pack_command(*arg_list) assert cmd[1] is arg cmds = c.pack_commands([arg_list, arg_list]) @@ -85,25 +94,25 @@ def test_memoryviews_are_not_packed(self): class TestCommandsAreNotEncoded: @pytest.fixture() def r(self, request): - return _get_client(redis.Redis, request=request, encoding='utf-16') + return _get_client(redis.Redis, request=request, encoding="utf-16") def test_basic_command(self, r): - r.set('hello', 'world') + r.set("hello", "world") class TestInvalidUserInput: def test_boolean_fails(self, r): with pytest.raises(redis.DataError): - r.set('a', True) + r.set("a", True) def test_none_fails(self, r): with pytest.raises(redis.DataError): - r.set('a', None) + r.set("a", None) def test_user_type_fails(self, r): class Foo: def __str__(self): - return 'Foo' + return "Foo" with pytest.raises(redis.DataError): - r.set('a', Foo()) + r.set("a", Foo()) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 402eccf0a2..359582909f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,19 +1,20 @@ import string + from redis.commands.helpers import ( delist, list_or_args, nativestr, + parse_to_dict, parse_to_list, quote_string, random_string, - parse_to_dict ) def test_list_or_args(): k = ["hello, world"] a = ["some", "argument", "list"] - assert list_or_args(k, a) == k+a + assert list_or_args(k, a) == k + a for i in ["banana", b"banana"]: assert list_or_args(i, a) == [i] + a @@ -22,42 +23,50 @@ def test_list_or_args(): def test_parse_to_list(): assert parse_to_list(None) == [] r = ["hello", b"my name", "45", "555.55", "is simon!", None] - assert parse_to_list(r) == \ - ["hello", "my name", 45, 555.55, "is simon!", None] + assert parse_to_list(r) == ["hello", "my name", 45, 555.55, "is simon!", None] def test_parse_to_dict(): assert parse_to_dict(None) == {} - r = [['Some number', '1.0345'], - ['Some string', 'hello'], - ['Child iterators', - ['Time', '0.2089', 'Counter', 3, 'Child iterators', - ['Type', 'bar', 'Time', '0.0729', 'Counter', 3], - ['Type', 'barbar', 'Time', '0.058', 'Counter', 3]]]] + r = [ + ["Some number", "1.0345"], + ["Some string", "hello"], + [ + "Child iterators", + [ + "Time", + "0.2089", + "Counter", + 3, + "Child iterators", + ["Type", "bar", "Time", "0.0729", "Counter", 3], + ["Type", "barbar", "Time", "0.058", "Counter", 3], + ], + ], + ] assert parse_to_dict(r) == { - 'Child iterators': { - 'Child iterators': [ - {'Counter': 3.0, 'Time': 0.0729, 'Type': 'bar'}, - {'Counter': 3.0, 'Time': 0.058, 'Type': 'barbar'} + "Child iterators": { + "Child iterators": [ + {"Counter": 3.0, "Time": 0.0729, "Type": "bar"}, + {"Counter": 3.0, "Time": 0.058, "Type": "barbar"}, ], - 'Counter': 3.0, - 'Time': 0.2089 + "Counter": 3.0, + "Time": 0.2089, }, - 'Some number': 1.0345, - 'Some string': 'hello' + "Some number": 1.0345, + "Some string": "hello", } def test_nativestr(): - assert nativestr('teststr') == 'teststr' - assert nativestr(b'teststr') == 'teststr' - assert nativestr('null') is None + assert nativestr("teststr") == "teststr" + assert nativestr(b"teststr") == "teststr" + assert nativestr("null") is None def test_delist(): assert delist(None) is None - assert delist([b'hello', 'world', b'banana']) == \ - ['hello', 'world', 'banana'] + assert delist([b"hello", "world", b"banana"]) == ["hello", "world", "banana"] def test_random_string(): @@ -69,5 +78,5 @@ def test_random_string(): def test_quote_string(): assert quote_string("hello world!") == '"hello world!"' - assert quote_string('') == '""' - assert quote_string('hello world!') == '"hello world!"' + assert quote_string("") == '""' + assert quote_string("hello world!") == '"hello world!"' diff --git a/tests/test_json.py b/tests/test_json.py index 187bfe2289..1686f9d05e 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1,8 +1,10 @@ import pytest + import redis -from redis.commands.json.path import Path from redis import exceptions -from redis.commands.json.decoders import unstring, decode_list +from redis.commands.json.decoders import decode_list, unstring +from redis.commands.json.path import Path + from .conftest import skip_ifmodversion_lt @@ -48,9 +50,7 @@ def test_json_get_jset(client): @pytest.mark.redismod def test_nonascii_setgetdelete(client): assert client.json().set("notascii", Path.rootPath(), "hyvää-élève") - assert "hyvää-élève" == client.json().get( - "notascii", - no_escape=True) + assert "hyvää-élève" == client.json().get("notascii", no_escape=True) assert 1 == client.json().delete("notascii") assert client.exists("notascii") == 0 @@ -179,7 +179,7 @@ def test_arrinsert(client): 1, 2, 3, - ] + ], ) assert [0, 1, 2, 3, 4] == client.json().get("arr") @@ -307,8 +307,7 @@ def test_json_delete_with_dollar(client): r = client.json().get("doc1", "$") assert r == [{"nested": {"b": 3}}] - doc2 = {"a": {"a": 2, "b": 3}, "b": [ - "a", "b"], "nested": {"b": [True, "a", "b"]}} + doc2 = {"a": {"a": 2, "b": 3}, "b": ["a", "b"], "nested": {"b": [True, "a", "b"]}} assert client.json().set("doc2", "$", doc2) assert client.json().delete("doc2", "$..a") == 1 res = client.json().get("doc2", "$") @@ -361,8 +360,7 @@ def test_json_forget_with_dollar(client): r = client.json().get("doc1", "$") assert r == [{"nested": {"b": 3}}] - doc2 = {"a": {"a": 2, "b": 3}, "b": [ - "a", "b"], "nested": {"b": [True, "a", "b"]}} + doc2 = {"a": {"a": 2, "b": 3}, "b": ["a", "b"], "nested": {"b": [True, "a", "b"]}} assert client.json().set("doc2", "$", doc2) assert client.json().forget("doc2", "$..a") == 1 res = client.json().get("doc2", "$") @@ -413,16 +411,12 @@ def test_json_mget_dollar(client): client.json().set( "doc1", "$", - {"a": 1, - "b": 2, - "nested": {"a": 3}, - "c": None, "nested2": {"a": None}}, + {"a": 1, "b": 2, "nested": {"a": 3}, "c": None, "nested2": {"a": None}}, ) client.json().set( "doc2", "$", - {"a": 4, "b": 5, "nested": {"a": 6}, - "c": None, "nested2": {"a": [None]}}, + {"a": 4, "b": 5, "nested": {"a": 6}, "c": None, "nested2": {"a": [None]}}, ) # Compare also to single JSON.GET assert client.json().get("doc1", "$..a") == [1, 3, None] @@ -431,8 +425,7 @@ def test_json_mget_dollar(client): # Test mget with single path 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]]] + client.json().mget(["doc1", "doc2"], "$..a") == [[1, 3, None], [4, 6, [None]]] # Test missing key client.json().mget(["doc1", "missing_doc"], "$..a") == [[1, 3, None], None] @@ -444,15 +437,11 @@ def test_json_mget_dollar(client): def test_numby_commands_dollar(client): # Test NUMINCRBY - client.json().set( - "doc1", - "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) + client.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) # Test multi - assert client.json().numincrby("doc1", "$..a", 2) == \ - [None, 4, 7.0, None] + assert client.json().numincrby("doc1", "$..a", 2) == [None, 4, 7.0, None] - assert client.json().numincrby("doc1", "$..a", 2.5) == \ - [None, 6.5, 9.5, None] + assert client.json().numincrby("doc1", "$..a", 2.5) == [None, 6.5, 9.5, None] # Test single assert client.json().numincrby("doc1", "$.b[1].a", 2) == [11.5] @@ -460,15 +449,12 @@ def test_numby_commands_dollar(client): assert client.json().numincrby("doc1", "$.b[1].a", 3.5) == [15.0] # Test NUMMULTBY - client.json().set("doc1", "$", {"a": "b", "b": [ - {"a": 2}, {"a": 5.0}, {"a": "c"}]}) + client.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) # test list with pytest.deprecated_call(): - assert client.json().nummultby("doc1", "$..a", 2) == \ - [None, 4, 10, None] - assert client.json().nummultby("doc1", "$..a", 2.5) == \ - [None, 10.0, 25.0, None] + assert client.json().nummultby("doc1", "$..a", 2) == [None, 4, 10, None] + assert client.json().nummultby("doc1", "$..a", 2.5) == [None, 10.0, 25.0, None] # Test single with pytest.deprecated_call(): @@ -482,13 +468,11 @@ def test_numby_commands_dollar(client): client.json().nummultby("non_existing_doc", "$..a", 2) # Test legacy NUMINCRBY - client.json().set("doc1", "$", {"a": "b", "b": [ - {"a": 2}, {"a": 5.0}, {"a": "c"}]}) + client.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) 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"}]}) + 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 @@ -498,8 +482,7 @@ def test_numby_commands_dollar(client): def test_strappend_dollar(client): client.json().set( - "doc1", "$", {"a": "foo", "nested1": { - "a": "hello"}, "nested2": {"a": 31}} + "doc1", "$", {"a": "foo", "nested1": {"a": "hello"}, "nested2": {"a": 31}} ) # Test multi client.json().strappend("doc1", "bar", "$..a") == [6, 8, None] @@ -534,8 +517,7 @@ def test_strlen_dollar(client): # Test multi client.json().set( - "doc1", "$", {"a": "foo", "nested1": { - "a": "hello"}, "nested2": {"a": 31}} + "doc1", "$", {"a": "foo", "nested1": {"a": "hello"}, "nested2": {"a": 31}} ) assert client.json().strlen("doc1", "$..a") == [3, 5, None] @@ -634,8 +616,7 @@ def test_arrinsert_dollar(client): }, ) # Test multi - assert client.json().arrinsert("doc1", "$..a", "1", - "bar", "racuda") == [3, 5, None] + assert client.json().arrinsert("doc1", "$..a", "1", "bar", "racuda") == [3, 5, None] assert client.json().get("doc1", "$") == [ { @@ -674,8 +655,11 @@ def test_arrlen_dollar(client): # Test multi assert client.json().arrlen("doc1", "$..a") == [1, 3, None] - assert client.json().arrappend("doc1", "$..a", "non", "abba", "stanza") \ - == [4, 6, None] + assert client.json().arrappend("doc1", "$..a", "non", "abba", "stanza") == [ + 4, + 6, + None, + ] client.json().clear("doc1", "$.a") assert client.json().arrlen("doc1", "$..a") == [0, 6, None] @@ -924,8 +908,7 @@ def test_clear_dollar(client): assert client.json().clear("doc1", "$..a") == 3 assert client.json().get("doc1", "$") == [ - {"nested1": {"a": {}}, "a": [], "nested2": { - "a": "claro"}, "nested3": {"a": {}}} + {"nested1": {"a": {}}, "a": [], "nested2": {"a": "claro"}, "nested3": {"a": {}}} ] # Test single @@ -994,8 +977,7 @@ def test_debug_dollar(client): client.json().set("doc1", "$", jdata) # Test multi - assert client.json().debug("MEMORY", "doc1", "$..a") == [ - 72, 24, 24, 16, 16, 1, 0] + assert client.json().debug("MEMORY", "doc1", "$..a") == [72, 24, 24, 16, 16, 1, 0] # Test single assert client.json().debug("MEMORY", "doc1", "$.nested2.a") == [24] @@ -1234,12 +1216,10 @@ def test_arrindex_dollar(client): [], ] - assert client.json().arrindex("test_num", "$..arr", 3) == [ - 3, 2, -1, None, -1] + assert client.json().arrindex("test_num", "$..arr", 3) == [3, 2, -1, None, -1] # Test index of double scalar in multi values - assert client.json().arrindex("test_num", "$..arr", 3.0) == [ - 2, 8, -1, None, -1] + assert client.json().arrindex("test_num", "$..arr", 3.0) == [2, 8, -1, None, -1] # Test index of string scalar in multi values client.json().set( @@ -1249,10 +1229,7 @@ def test_arrindex_dollar(client): {"arr": ["bazzz", "bar", 2, "baz", 2, "ba", "baz", 3]}, { "nested1_found": { - "arr": [ - None, - "baz2", - "buzz", 2, 1, 0, 1, "2", "baz", 2, 4, 5] + "arr": [None, "baz2", "buzz", 2, 1, 0, 1, "2", "baz", 2, 4, 5] } }, {"nested2_not_found": {"arr": ["baz2", 4, 6]}}, @@ -1344,11 +1321,7 @@ def test_arrindex_dollar(client): {"arr": ["bazzz", "None", 2, None, 2, "ba", "baz", 3]}, { "nested1_found": { - "arr": [ - "zaz", - "baz2", - "buzz", - 2, 1, 0, 1, "2", None, 2, 4, 5] + "arr": ["zaz", "baz2", "buzz", 2, 1, 0, 1, "2", None, 2, 4, 5] } }, {"nested2_not_found": {"arr": ["None", 4, 6]}}, @@ -1369,8 +1342,7 @@ def test_arrindex_dollar(client): # Fail with none-scalar value with pytest.raises(exceptions.ResponseError): - client.json().arrindex( - "test_None", "$..nested42_empty_arr.arr", {"arr": []}) + client.json().arrindex("test_None", "$..nested42_empty_arr.arr", {"arr": []}) # Do not fail with none-scalar value in legacy mode assert ( @@ -1392,10 +1364,7 @@ def test_arrindex_dollar(client): assert client.json().arrindex("test_string", ".[0].arr", "faz") == -1 # Test index of None scalar in single value assert client.json().arrindex("test_None", ".[0].arr", "None") == 1 - assert client.json().arrindex( - "test_None", - "..nested2_not_found.arr", - "None") == 0 + assert client.json().arrindex("test_None", "..nested2_not_found.arr", "None") == 0 @pytest.mark.redismod @@ -1406,14 +1375,15 @@ def test_decoders_and_unstring(): assert decode_list(b"45.55") == 45.55 assert decode_list("45.55") == 45.55 - assert decode_list(['hello', b'world']) == ['hello', 'world'] + assert decode_list(["hello", b"world"]) == ["hello", "world"] @pytest.mark.redismod def test_custom_decoder(client): - import ujson import json + import ujson + cj = client.json(encoder=ujson, decoder=ujson) assert cj.set("foo", Path.rootPath(), "bar") assert "bar" == cj.get("foo") diff --git a/tests/test_lock.py b/tests/test_lock.py index 66148edcfc..02cca1b522 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -1,9 +1,11 @@ -import pytest import time -from redis.exceptions import LockError, LockNotOwnedError +import pytest + from redis.client import Redis +from redis.exceptions import LockError, LockNotOwnedError from redis.lock import Lock + from .conftest import _get_client @@ -14,36 +16,36 @@ def r_decoded(self, request): return _get_client(Redis, request=request, decode_responses=True) def get_lock(self, redis, *args, **kwargs): - kwargs['lock_class'] = Lock + kwargs["lock_class"] = Lock return redis.lock(*args, **kwargs) def test_lock(self, r): - lock = self.get_lock(r, 'foo') + lock = self.get_lock(r, "foo") assert lock.acquire(blocking=False) - assert r.get('foo') == lock.local.token - assert r.ttl('foo') == -1 + assert r.get("foo") == lock.local.token + assert r.ttl("foo") == -1 lock.release() - assert r.get('foo') is None + assert r.get("foo") is None def test_lock_token(self, r): - lock = self.get_lock(r, 'foo') + lock = self.get_lock(r, "foo") self._test_lock_token(r, lock) def test_lock_token_thread_local_false(self, r): - lock = self.get_lock(r, 'foo', thread_local=False) + lock = self.get_lock(r, "foo", thread_local=False) self._test_lock_token(r, lock) def _test_lock_token(self, r, lock): - assert lock.acquire(blocking=False, token='test') - assert r.get('foo') == b'test' - assert lock.local.token == b'test' - assert r.ttl('foo') == -1 + assert lock.acquire(blocking=False, token="test") + assert r.get("foo") == b"test" + assert lock.local.token == b"test" + assert r.ttl("foo") == -1 lock.release() - assert r.get('foo') is None + assert r.get("foo") is None assert lock.local.token is None def test_locked(self, r): - lock = self.get_lock(r, 'foo') + lock = self.get_lock(r, "foo") assert lock.locked() is False lock.acquire(blocking=False) assert lock.locked() is True @@ -51,14 +53,14 @@ def test_locked(self, r): assert lock.locked() is False def _test_owned(self, client): - lock = self.get_lock(client, 'foo') + lock = self.get_lock(client, "foo") assert lock.owned() is False lock.acquire(blocking=False) assert lock.owned() is True lock.release() assert lock.owned() is False - lock2 = self.get_lock(client, 'foo') + lock2 = self.get_lock(client, "foo") assert lock.owned() is False assert lock2.owned() is False lock2.acquire(blocking=False) @@ -75,8 +77,8 @@ def test_owned_with_decoded_responses(self, r_decoded): self._test_owned(r_decoded) def test_competing_locks(self, r): - lock1 = self.get_lock(r, 'foo') - lock2 = self.get_lock(r, 'foo') + lock1 = self.get_lock(r, "foo") + lock2 = self.get_lock(r, "foo") assert lock1.acquire(blocking=False) assert not lock2.acquire(blocking=False) lock1.release() @@ -85,23 +87,23 @@ def test_competing_locks(self, r): lock2.release() def test_timeout(self, r): - lock = self.get_lock(r, 'foo', timeout=10) + lock = self.get_lock(r, "foo", timeout=10) assert lock.acquire(blocking=False) - assert 8 < r.ttl('foo') <= 10 + assert 8 < r.ttl("foo") <= 10 lock.release() def test_float_timeout(self, r): - lock = self.get_lock(r, 'foo', timeout=9.5) + lock = self.get_lock(r, "foo", timeout=9.5) assert lock.acquire(blocking=False) - assert 8 < r.pttl('foo') <= 9500 + assert 8 < r.pttl("foo") <= 9500 lock.release() def test_blocking_timeout(self, r): - lock1 = self.get_lock(r, 'foo') + lock1 = self.get_lock(r, "foo") assert lock1.acquire(blocking=False) bt = 0.2 sleep = 0.05 - lock2 = self.get_lock(r, 'foo', sleep=sleep, blocking_timeout=bt) + lock2 = self.get_lock(r, "foo", sleep=sleep, blocking_timeout=bt) start = time.monotonic() assert not lock2.acquire() # The elapsed duration should be less than the total blocking_timeout @@ -111,22 +113,22 @@ def test_blocking_timeout(self, r): def test_context_manager(self, r): # blocking_timeout prevents a deadlock if the lock can't be acquired # for some reason - with self.get_lock(r, 'foo', blocking_timeout=0.2) as lock: - assert r.get('foo') == lock.local.token - assert r.get('foo') is None + with self.get_lock(r, "foo", blocking_timeout=0.2) as lock: + assert r.get("foo") == lock.local.token + assert r.get("foo") is None def test_context_manager_raises_when_locked_not_acquired(self, r): - r.set('foo', 'bar') + r.set("foo", "bar") with pytest.raises(LockError): - with self.get_lock(r, 'foo', blocking_timeout=0.1): + with self.get_lock(r, "foo", blocking_timeout=0.1): pass def test_high_sleep_small_blocking_timeout(self, r): - lock1 = self.get_lock(r, 'foo') + lock1 = self.get_lock(r, "foo") assert lock1.acquire(blocking=False) sleep = 60 bt = 1 - lock2 = self.get_lock(r, 'foo', sleep=sleep, blocking_timeout=bt) + lock2 = self.get_lock(r, "foo", sleep=sleep, blocking_timeout=bt) start = time.monotonic() assert not lock2.acquire() # the elapsed timed is less than the blocking_timeout as the lock is @@ -135,88 +137,88 @@ def test_high_sleep_small_blocking_timeout(self, r): lock1.release() def test_releasing_unlocked_lock_raises_error(self, r): - lock = self.get_lock(r, 'foo') + lock = self.get_lock(r, "foo") with pytest.raises(LockError): lock.release() def test_releasing_lock_no_longer_owned_raises_error(self, r): - lock = self.get_lock(r, 'foo') + lock = self.get_lock(r, "foo") lock.acquire(blocking=False) # manually change the token - r.set('foo', 'a') + r.set("foo", "a") with pytest.raises(LockNotOwnedError): lock.release() # even though we errored, the token is still cleared assert lock.local.token is None def test_extend_lock(self, r): - lock = self.get_lock(r, 'foo', timeout=10) + lock = self.get_lock(r, "foo", timeout=10) assert lock.acquire(blocking=False) - assert 8000 < r.pttl('foo') <= 10000 + assert 8000 < r.pttl("foo") <= 10000 assert lock.extend(10) - assert 16000 < r.pttl('foo') <= 20000 + assert 16000 < r.pttl("foo") <= 20000 lock.release() def test_extend_lock_replace_ttl(self, r): - lock = self.get_lock(r, 'foo', timeout=10) + lock = self.get_lock(r, "foo", timeout=10) assert lock.acquire(blocking=False) - assert 8000 < r.pttl('foo') <= 10000 + assert 8000 < r.pttl("foo") <= 10000 assert lock.extend(10, replace_ttl=True) - assert 8000 < r.pttl('foo') <= 10000 + assert 8000 < r.pttl("foo") <= 10000 lock.release() def test_extend_lock_float(self, r): - lock = self.get_lock(r, 'foo', timeout=10.0) + lock = self.get_lock(r, "foo", timeout=10.0) assert lock.acquire(blocking=False) - assert 8000 < r.pttl('foo') <= 10000 + assert 8000 < r.pttl("foo") <= 10000 assert lock.extend(10.0) - assert 16000 < r.pttl('foo') <= 20000 + assert 16000 < r.pttl("foo") <= 20000 lock.release() def test_extending_unlocked_lock_raises_error(self, r): - lock = self.get_lock(r, 'foo', timeout=10) + lock = self.get_lock(r, "foo", timeout=10) with pytest.raises(LockError): lock.extend(10) def test_extending_lock_with_no_timeout_raises_error(self, r): - lock = self.get_lock(r, 'foo') + lock = self.get_lock(r, "foo") assert lock.acquire(blocking=False) with pytest.raises(LockError): lock.extend(10) lock.release() def test_extending_lock_no_longer_owned_raises_error(self, r): - lock = self.get_lock(r, 'foo', timeout=10) + lock = self.get_lock(r, "foo", timeout=10) assert lock.acquire(blocking=False) - r.set('foo', 'a') + r.set("foo", "a") with pytest.raises(LockNotOwnedError): lock.extend(10) def test_reacquire_lock(self, r): - lock = self.get_lock(r, 'foo', timeout=10) + lock = self.get_lock(r, "foo", timeout=10) assert lock.acquire(blocking=False) - assert r.pexpire('foo', 5000) - assert r.pttl('foo') <= 5000 + assert r.pexpire("foo", 5000) + assert r.pttl("foo") <= 5000 assert lock.reacquire() - assert 8000 < r.pttl('foo') <= 10000 + assert 8000 < r.pttl("foo") <= 10000 lock.release() def test_reacquiring_unlocked_lock_raises_error(self, r): - lock = self.get_lock(r, 'foo', timeout=10) + lock = self.get_lock(r, "foo", timeout=10) with pytest.raises(LockError): lock.reacquire() def test_reacquiring_lock_with_no_timeout_raises_error(self, r): - lock = self.get_lock(r, 'foo') + lock = self.get_lock(r, "foo") assert lock.acquire(blocking=False) with pytest.raises(LockError): lock.reacquire() lock.release() def test_reacquiring_lock_no_longer_owned_raises_error(self, r): - lock = self.get_lock(r, 'foo', timeout=10) + lock = self.get_lock(r, "foo", timeout=10) assert lock.acquire(blocking=False) - r.set('foo', 'a') + r.set("foo", "a") with pytest.raises(LockNotOwnedError): lock.reacquire() @@ -228,5 +230,6 @@ class MyLock: def __init__(self, *args, **kwargs): pass - lock = r.lock('foo', lock_class=MyLock) + + lock = r.lock("foo", lock_class=MyLock) assert type(lock) == MyLock diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 6c3ea33bce..40d9e43094 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -1,8 +1,9 @@ import pytest + from .conftest import ( skip_if_redis_enterprise, skip_ifnot_redis_enterprise, - wait_for_command + wait_for_command, ) @@ -11,56 +12,56 @@ class TestMonitor: def test_wait_command_not_found(self, r): "Make sure the wait_for_command func works when command is not found" with r.monitor() as m: - response = wait_for_command(r, m, 'nothing') + response = wait_for_command(r, m, "nothing") assert response is None def test_response_values(self, r): - db = r.connection_pool.connection_kwargs.get('db', 0) + db = r.connection_pool.connection_kwargs.get("db", 0) with r.monitor() as m: r.ping() - response = wait_for_command(r, m, 'PING') - assert isinstance(response['time'], float) - assert response['db'] == db - assert response['client_type'] in ('tcp', 'unix') - assert isinstance(response['client_address'], str) - assert isinstance(response['client_port'], str) - assert response['command'] == 'PING' + response = wait_for_command(r, m, "PING") + assert isinstance(response["time"], float) + assert response["db"] == db + assert response["client_type"] in ("tcp", "unix") + assert isinstance(response["client_address"], str) + assert isinstance(response["client_port"], str) + assert response["command"] == "PING" def test_command_with_quoted_key(self, r): with r.monitor() as m: r.get('foo"bar') response = wait_for_command(r, m, 'GET foo"bar') - assert response['command'] == 'GET foo"bar' + assert response["command"] == 'GET foo"bar' def test_command_with_binary_data(self, r): with r.monitor() as m: - byte_string = b'foo\x92' + byte_string = b"foo\x92" r.get(byte_string) - response = wait_for_command(r, m, 'GET foo\\x92') - assert response['command'] == 'GET foo\\x92' + response = wait_for_command(r, m, "GET foo\\x92") + assert response["command"] == "GET foo\\x92" def test_command_with_escaped_data(self, r): with r.monitor() as m: - byte_string = b'foo\\x92' + byte_string = b"foo\\x92" r.get(byte_string) - response = wait_for_command(r, m, 'GET foo\\\\x92') - assert response['command'] == 'GET foo\\\\x92' + response = wait_for_command(r, m, "GET foo\\\\x92") + assert response["command"] == "GET foo\\\\x92" @skip_if_redis_enterprise def test_lua_script(self, r): with r.monitor() as m: script = 'return redis.call("GET", "foo")' assert r.eval(script, 0) is None - response = wait_for_command(r, m, 'GET foo') - assert response['command'] == 'GET foo' - assert response['client_type'] == 'lua' - assert response['client_address'] == 'lua' - assert response['client_port'] == '' + response = wait_for_command(r, m, "GET foo") + assert response["command"] == "GET foo" + assert response["client_type"] == "lua" + assert response["client_address"] == "lua" + assert response["client_port"] == "" @skip_ifnot_redis_enterprise def test_lua_script_in_enterprise(self, r): with r.monitor() as m: script = 'return redis.call("GET", "foo")' assert r.eval(script, 0) is None - response = wait_for_command(r, m, 'GET foo') + response = wait_for_command(r, m, "GET foo") assert response is None diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py index 5968b2b4fe..32f5e23d53 100644 --- a/tests/test_multiprocessing.py +++ b/tests/test_multiprocessing.py @@ -1,6 +1,7 @@ -import pytest -import multiprocessing import contextlib +import multiprocessing + +import pytest import redis from redis.connection import Connection, ConnectionPool @@ -25,10 +26,7 @@ class TestMultiprocessing: # actually fork/process-safe @pytest.fixture() def r(self, request): - return _get_client( - redis.Redis, - request=request, - single_connection_client=False) + return _get_client(redis.Redis, request=request, single_connection_client=False) def test_close_connection_in_child(self, master_host): """ @@ -36,12 +34,12 @@ def test_close_connection_in_child(self, master_host): destroy the file descriptors so a parent can still use it. """ conn = Connection(host=master_host[0], port=master_host[1]) - conn.send_command('ping') - assert conn.read_response() == b'PONG' + conn.send_command("ping") + assert conn.read_response() == b"PONG" def target(conn): - conn.send_command('ping') - assert conn.read_response() == b'PONG' + conn.send_command("ping") + assert conn.read_response() == b"PONG" conn.disconnect() proc = multiprocessing.Process(target=target, args=(conn,)) @@ -53,8 +51,8 @@ def target(conn): # child. The child called socket.close() but did not call # socket.shutdown() because it wasn't the "owning" process. # Therefore the connection still works in the parent. - conn.send_command('ping') - assert conn.read_response() == b'PONG' + conn.send_command("ping") + assert conn.read_response() == b"PONG" def test_close_connection_in_parent(self, master_host): """ @@ -62,8 +60,8 @@ def test_close_connection_in_parent(self, master_host): (the owning process) closes the connection. """ conn = Connection(host=master_host[0], port=master_host[1]) - conn.send_command('ping') - assert conn.read_response() == b'PONG' + conn.send_command("ping") + assert conn.read_response() == b"PONG" def target(conn, ev): ev.wait() @@ -71,7 +69,7 @@ def target(conn, ev): # connection, the connection is shutdown and the child # cannot use it. with pytest.raises(ConnectionError): - conn.send_command('ping') + conn.send_command("ping") ev = multiprocessing.Event() proc = multiprocessing.Process(target=target, args=(conn, ev)) @@ -83,28 +81,30 @@ def target(conn, ev): proc.join(3) assert proc.exitcode == 0 - @pytest.mark.parametrize('max_connections', [1, 2, None]) + @pytest.mark.parametrize("max_connections", [1, 2, None]) def test_pool(self, max_connections, master_host): """ A child will create its own connections when using a pool created by a parent. """ - pool = ConnectionPool.from_url(f'redis://{master_host[0]}:{master_host[1]}', - max_connections=max_connections) + pool = ConnectionPool.from_url( + f"redis://{master_host[0]}:{master_host[1]}", + max_connections=max_connections, + ) - conn = pool.get_connection('ping') + conn = pool.get_connection("ping") main_conn_pid = conn.pid with exit_callback(pool.release, conn): - conn.send_command('ping') - assert conn.read_response() == b'PONG' + conn.send_command("ping") + assert conn.read_response() == b"PONG" def target(pool): with exit_callback(pool.disconnect): - conn = pool.get_connection('ping') + conn = pool.get_connection("ping") assert conn.pid != main_conn_pid with exit_callback(pool.release, conn): - assert conn.send_command('ping') is None - assert conn.read_response() == b'PONG' + assert conn.send_command("ping") is None + assert conn.read_response() == b"PONG" proc = multiprocessing.Process(target=target, args=(pool,)) proc.start() @@ -113,32 +113,34 @@ def target(pool): # Check that connection is still alive after fork process has exited # and disconnected the connections in its pool - conn = pool.get_connection('ping') + conn = pool.get_connection("ping") with exit_callback(pool.release, conn): - assert conn.send_command('ping') is None - assert conn.read_response() == b'PONG' + assert conn.send_command("ping") is None + assert conn.read_response() == b"PONG" - @pytest.mark.parametrize('max_connections', [1, 2, None]) + @pytest.mark.parametrize("max_connections", [1, 2, None]) def test_close_pool_in_main(self, max_connections, master_host): """ A child process that uses the same pool as its parent isn't affected when the parent disconnects all connections within the pool. """ - pool = ConnectionPool.from_url(f'redis://{master_host[0]}:{master_host[1]}', - max_connections=max_connections) + pool = ConnectionPool.from_url( + f"redis://{master_host[0]}:{master_host[1]}", + max_connections=max_connections, + ) - conn = pool.get_connection('ping') - assert conn.send_command('ping') is None - assert conn.read_response() == b'PONG' + conn = pool.get_connection("ping") + assert conn.send_command("ping") is None + assert conn.read_response() == b"PONG" def target(pool, disconnect_event): - conn = pool.get_connection('ping') + conn = pool.get_connection("ping") with exit_callback(pool.release, conn): - assert conn.send_command('ping') is None - assert conn.read_response() == b'PONG' + assert conn.send_command("ping") is None + assert conn.read_response() == b"PONG" disconnect_event.wait() - assert conn.send_command('ping') is None - assert conn.read_response() == b'PONG' + assert conn.send_command("ping") is None + assert conn.read_response() == b"PONG" ev = multiprocessing.Event() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index a87ed7182d..0518893f07 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,7 +1,8 @@ import pytest import redis -from .conftest import wait_for_command, skip_if_server_version_lt + +from .conftest import skip_if_server_version_lt, wait_for_command class TestPipeline: @@ -12,31 +13,30 @@ def test_pipeline_is_true(self, r): def test_pipeline(self, r): with r.pipeline() as pipe: - (pipe.set('a', 'a1') - .get('a') - .zadd('z', {'z1': 1}) - .zadd('z', {'z2': 4}) - .zincrby('z', 1, 'z1') - .zrange('z', 0, 5, withscores=True)) - assert pipe.execute() == \ - [ - True, - b'a1', - True, - True, - 2.0, - [(b'z1', 2.0), (b'z2', 4)], - ] + ( + pipe.set("a", "a1") + .get("a") + .zadd("z", {"z1": 1}) + .zadd("z", {"z2": 4}) + .zincrby("z", 1, "z1") + .zrange("z", 0, 5, withscores=True) + ) + assert pipe.execute() == [ + True, + b"a1", + True, + True, + 2.0, + [(b"z1", 2.0), (b"z2", 4)], + ] def test_pipeline_memoryview(self, r): with r.pipeline() as pipe: - (pipe.set('a', memoryview(b'a1')) - .get('a')) - assert pipe.execute() == \ - [ - True, - b'a1', - ] + (pipe.set("a", memoryview(b"a1")).get("a")) + assert pipe.execute() == [ + True, + b"a1", + ] def test_pipeline_length(self, r): with r.pipeline() as pipe: @@ -44,7 +44,7 @@ def test_pipeline_length(self, r): assert len(pipe) == 0 # Fill 'er up! - pipe.set('a', 'a1').set('b', 'b1').set('c', 'c1') + pipe.set("a", "a1").set("b", "b1").set("c", "c1") assert len(pipe) == 3 # Execute calls reset(), so empty once again. @@ -53,83 +53,84 @@ def test_pipeline_length(self, r): def test_pipeline_no_transaction(self, r): with r.pipeline(transaction=False) as pipe: - pipe.set('a', 'a1').set('b', 'b1').set('c', 'c1') + pipe.set("a", "a1").set("b", "b1").set("c", "c1") assert pipe.execute() == [True, True, True] - assert r['a'] == b'a1' - assert r['b'] == b'b1' - assert r['c'] == b'c1' + assert r["a"] == b"a1" + assert r["b"] == b"b1" + assert r["c"] == b"c1" @pytest.mark.onlynoncluster def test_pipeline_no_transaction_watch(self, r): - r['a'] = 0 + r["a"] = 0 with r.pipeline(transaction=False) as pipe: - pipe.watch('a') - a = pipe.get('a') + pipe.watch("a") + a = pipe.get("a") pipe.multi() - pipe.set('a', int(a) + 1) + pipe.set("a", int(a) + 1) assert pipe.execute() == [True] @pytest.mark.onlynoncluster def test_pipeline_no_transaction_watch_failure(self, r): - r['a'] = 0 + r["a"] = 0 with r.pipeline(transaction=False) as pipe: - pipe.watch('a') - a = pipe.get('a') + pipe.watch("a") + a = pipe.get("a") - r['a'] = 'bad' + r["a"] = "bad" pipe.multi() - pipe.set('a', int(a) + 1) + pipe.set("a", int(a) + 1) with pytest.raises(redis.WatchError): pipe.execute() - assert r['a'] == b'bad' + assert r["a"] == b"bad" def test_exec_error_in_response(self, r): """ an invalid pipeline command at exec time adds the exception instance to the list of returned values """ - r['c'] = 'a' + r["c"] = "a" with r.pipeline() as pipe: - pipe.set('a', 1).set('b', 2).lpush('c', 3).set('d', 4) + pipe.set("a", 1).set("b", 2).lpush("c", 3).set("d", 4) result = pipe.execute(raise_on_error=False) assert result[0] - assert r['a'] == b'1' + assert r["a"] == b"1" assert result[1] - assert r['b'] == b'2' + assert r["b"] == b"2" # we can't lpush to a key that's a string value, so this should # be a ResponseError exception assert isinstance(result[2], redis.ResponseError) - assert r['c'] == b'a' + assert r["c"] == b"a" # since this isn't a transaction, the other commands after the # error are still executed assert result[3] - assert r['d'] == b'4' + assert r["d"] == b"4" # make sure the pipe was restored to a working state - assert pipe.set('z', 'zzz').execute() == [True] - assert r['z'] == b'zzz' + assert pipe.set("z", "zzz").execute() == [True] + assert r["z"] == b"zzz" def test_exec_error_raised(self, r): - r['c'] = 'a' + r["c"] = "a" with r.pipeline() as pipe: - pipe.set('a', 1).set('b', 2).lpush('c', 3).set('d', 4) + pipe.set("a", 1).set("b", 2).lpush("c", 3).set("d", 4) with pytest.raises(redis.ResponseError) as ex: pipe.execute() - assert str(ex.value).startswith('Command # 3 (LPUSH c 3) of ' - 'pipeline caused error: ') + assert str(ex.value).startswith( + "Command # 3 (LPUSH c 3) of " "pipeline caused error: " + ) # make sure the pipe was restored to a working state - assert pipe.set('z', 'zzz').execute() == [True] - assert r['z'] == b'zzz' + assert pipe.set("z", "zzz").execute() == [True] + assert r["z"] == b"zzz" @pytest.mark.onlynoncluster def test_transaction_with_empty_error_command(self, r): @@ -139,7 +140,7 @@ def test_transaction_with_empty_error_command(self, r): """ for error_switch in (True, False): with r.pipeline() as pipe: - pipe.set('a', 1).mget([]).set('c', 3) + pipe.set("a", 1).mget([]).set("c", 3) result = pipe.execute(raise_on_error=error_switch) assert result[0] @@ -154,7 +155,7 @@ def test_pipeline_with_empty_error_command(self, r): """ for error_switch in (True, False): with r.pipeline(transaction=False) as pipe: - pipe.set('a', 1).mget([]).set('c', 3) + pipe.set("a", 1).mget([]).set("c", 3) result = pipe.execute(raise_on_error=error_switch) assert result[0] @@ -164,61 +165,63 @@ def test_pipeline_with_empty_error_command(self, r): def test_parse_error_raised(self, r): with r.pipeline() as pipe: # the zrem is invalid because we don't pass any keys to it - pipe.set('a', 1).zrem('b').set('b', 2) + pipe.set("a", 1).zrem("b").set("b", 2) with pytest.raises(redis.ResponseError) as ex: pipe.execute() - assert str(ex.value).startswith('Command # 2 (ZREM b) of ' - 'pipeline caused error: ') + assert str(ex.value).startswith( + "Command # 2 (ZREM b) of " "pipeline caused error: " + ) # make sure the pipe was restored to a working state - assert pipe.set('z', 'zzz').execute() == [True] - assert r['z'] == b'zzz' + assert pipe.set("z", "zzz").execute() == [True] + assert r["z"] == b"zzz" @pytest.mark.onlynoncluster def test_parse_error_raised_transaction(self, r): with r.pipeline() as pipe: pipe.multi() # the zrem is invalid because we don't pass any keys to it - pipe.set('a', 1).zrem('b').set('b', 2) + pipe.set("a", 1).zrem("b").set("b", 2) with pytest.raises(redis.ResponseError) as ex: pipe.execute() - assert str(ex.value).startswith('Command # 2 (ZREM b) of ' - 'pipeline caused error: ') + assert str(ex.value).startswith( + "Command # 2 (ZREM b) of " "pipeline caused error: " + ) # make sure the pipe was restored to a working state - assert pipe.set('z', 'zzz').execute() == [True] - assert r['z'] == b'zzz' + assert pipe.set("z", "zzz").execute() == [True] + assert r["z"] == b"zzz" @pytest.mark.onlynoncluster def test_watch_succeed(self, r): - r['a'] = 1 - r['b'] = 2 + r["a"] = 1 + r["b"] = 2 with r.pipeline() as pipe: - pipe.watch('a', 'b') + pipe.watch("a", "b") assert pipe.watching - a_value = pipe.get('a') - b_value = pipe.get('b') - assert a_value == b'1' - assert b_value == b'2' + a_value = pipe.get("a") + b_value = pipe.get("b") + assert a_value == b"1" + assert b_value == b"2" pipe.multi() - pipe.set('c', 3) + pipe.set("c", 3) assert pipe.execute() == [True] assert not pipe.watching @pytest.mark.onlynoncluster def test_watch_failure(self, r): - r['a'] = 1 - r['b'] = 2 + r["a"] = 1 + r["b"] = 2 with r.pipeline() as pipe: - pipe.watch('a', 'b') - r['b'] = 3 + pipe.watch("a", "b") + r["b"] = 3 pipe.multi() - pipe.get('a') + pipe.get("a") with pytest.raises(redis.WatchError): pipe.execute() @@ -226,12 +229,12 @@ def test_watch_failure(self, r): @pytest.mark.onlynoncluster def test_watch_failure_in_empty_transaction(self, r): - r['a'] = 1 - r['b'] = 2 + r["a"] = 1 + r["b"] = 2 with r.pipeline() as pipe: - pipe.watch('a', 'b') - r['b'] = 3 + pipe.watch("a", "b") + r["b"] = 3 pipe.multi() with pytest.raises(redis.WatchError): pipe.execute() @@ -240,103 +243,104 @@ def test_watch_failure_in_empty_transaction(self, r): @pytest.mark.onlynoncluster def test_unwatch(self, r): - r['a'] = 1 - r['b'] = 2 + r["a"] = 1 + r["b"] = 2 with r.pipeline() as pipe: - pipe.watch('a', 'b') - r['b'] = 3 + pipe.watch("a", "b") + r["b"] = 3 pipe.unwatch() assert not pipe.watching - pipe.get('a') - assert pipe.execute() == [b'1'] + pipe.get("a") + assert pipe.execute() == [b"1"] @pytest.mark.onlynoncluster def test_watch_exec_no_unwatch(self, r): - r['a'] = 1 - r['b'] = 2 + r["a"] = 1 + r["b"] = 2 with r.monitor() as m: with r.pipeline() as pipe: - pipe.watch('a', 'b') + pipe.watch("a", "b") assert pipe.watching - a_value = pipe.get('a') - b_value = pipe.get('b') - assert a_value == b'1' - assert b_value == b'2' + a_value = pipe.get("a") + b_value = pipe.get("b") + assert a_value == b"1" + assert b_value == b"2" pipe.multi() - pipe.set('c', 3) + pipe.set("c", 3) assert pipe.execute() == [True] assert not pipe.watching - unwatch_command = wait_for_command(r, m, 'UNWATCH') + unwatch_command = wait_for_command(r, m, "UNWATCH") assert unwatch_command is None, "should not send UNWATCH" @pytest.mark.onlynoncluster def test_watch_reset_unwatch(self, r): - r['a'] = 1 + r["a"] = 1 with r.monitor() as m: with r.pipeline() as pipe: - pipe.watch('a') + pipe.watch("a") assert pipe.watching pipe.reset() assert not pipe.watching - unwatch_command = wait_for_command(r, m, 'UNWATCH') + unwatch_command = wait_for_command(r, m, "UNWATCH") assert unwatch_command is not None - assert unwatch_command['command'] == 'UNWATCH' + assert unwatch_command["command"] == "UNWATCH" @pytest.mark.onlynoncluster def test_transaction_callable(self, r): - r['a'] = 1 - r['b'] = 2 + r["a"] = 1 + r["b"] = 2 has_run = [] def my_transaction(pipe): - a_value = pipe.get('a') - assert a_value in (b'1', b'2') - b_value = pipe.get('b') - assert b_value == b'2' + a_value = pipe.get("a") + assert a_value in (b"1", b"2") + b_value = pipe.get("b") + assert b_value == b"2" # silly run-once code... incr's "a" so WatchError should be raised # forcing this all to run again. this should incr "a" once to "2" if not has_run: - r.incr('a') - has_run.append('it has') + r.incr("a") + has_run.append("it has") pipe.multi() - pipe.set('c', int(a_value) + int(b_value)) + pipe.set("c", int(a_value) + int(b_value)) - result = r.transaction(my_transaction, 'a', 'b') + result = r.transaction(my_transaction, "a", "b") assert result == [True] - assert r['c'] == b'4' + assert r["c"] == b"4" @pytest.mark.onlynoncluster def test_transaction_callable_returns_value_from_callable(self, r): def callback(pipe): # No need to do anything here since we only want the return value - return 'a' + return "a" - res = r.transaction(callback, 'my-key', value_from_callable=True) - assert res == 'a' + res = r.transaction(callback, "my-key", value_from_callable=True) + assert res == "a" def test_exec_error_in_no_transaction_pipeline(self, r): - r['a'] = 1 + r["a"] = 1 with r.pipeline(transaction=False) as pipe: - pipe.llen('a') - pipe.expire('a', 100) + pipe.llen("a") + pipe.expire("a", 100) with pytest.raises(redis.ResponseError) as ex: pipe.execute() - assert str(ex.value).startswith('Command # 1 (LLEN a) of ' - 'pipeline caused error: ') + assert str(ex.value).startswith( + "Command # 1 (LLEN a) of " "pipeline caused error: " + ) - assert r['a'] == b'1' + assert r["a"] == b"1" def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r): - key = chr(3456) + 'abcd' + chr(3421) + key = chr(3456) + "abcd" + chr(3421) r[key] = 1 with r.pipeline(transaction=False) as pipe: pipe.llen(key) @@ -345,51 +349,52 @@ def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r): with pytest.raises(redis.ResponseError) as ex: pipe.execute() - expected = f'Command # 1 (LLEN {key}) of pipeline caused error: ' + expected = f"Command # 1 (LLEN {key}) of pipeline caused error: " assert str(ex.value).startswith(expected) - assert r[key] == b'1' + assert r[key] == b"1" def test_pipeline_with_bitfield(self, r): with r.pipeline() as pipe: - pipe.set('a', '1') - bf = pipe.bitfield('b') - pipe2 = (bf - .set('u8', 8, 255) - .get('u8', 0) - .get('u4', 8) # 1111 - .get('u4', 12) # 1111 - .get('u4', 13) # 1110 - .execute()) - pipe.get('a') + pipe.set("a", "1") + bf = pipe.bitfield("b") + pipe2 = ( + bf.set("u8", 8, 255) + .get("u8", 0) + .get("u4", 8) # 1111 + .get("u4", 12) # 1111 + .get("u4", 13) # 1110 + .execute() + ) + pipe.get("a") response = pipe.execute() assert pipe == pipe2 - assert response == [True, [0, 0, 15, 15, 14], b'1'] + assert response == [True, [0, 0, 15, 15, 14], b"1"] @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.0.0') + @skip_if_server_version_lt("2.0.0") def test_pipeline_discard(self, r): # empty pipeline should raise an error with r.pipeline() as pipe: - pipe.set('key', 'someval') + pipe.set("key", "someval") pipe.discard() with pytest.raises(redis.exceptions.ResponseError): pipe.execute() # setting a pipeline and discarding should do the same with r.pipeline() as pipe: - pipe.set('key', 'someval') - pipe.set('someotherkey', 'val') + pipe.set("key", "someval") + pipe.set("someotherkey", "val") response = pipe.execute() - pipe.set('key', 'another value!') + pipe.set("key", "another value!") pipe.discard() - pipe.set('key', 'another vae!') + pipe.set("key", "another vae!") with pytest.raises(redis.exceptions.ResponseError): pipe.execute() - pipe.set('foo', 'bar') + pipe.set("foo", "bar") response = pipe.execute() assert response[0] - assert r.get('foo') == b'bar' + assert r.get("foo") == b"bar" diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index b019bae6e2..6df0fafd4b 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -1,17 +1,14 @@ +import platform import threading import time from unittest import mock -import platform import pytest + import redis from redis.exceptions import ConnectionError -from .conftest import ( - _get_client, - skip_if_redis_enterprise, - skip_if_server_version_lt -) +from .conftest import _get_client, skip_if_redis_enterprise, skip_if_server_version_lt def wait_for_message(pubsub, timeout=0.1, ignore_subscribe_messages=False): @@ -19,7 +16,8 @@ def wait_for_message(pubsub, timeout=0.1, ignore_subscribe_messages=False): timeout = now + timeout while now < timeout: message = pubsub.get_message( - ignore_subscribe_messages=ignore_subscribe_messages) + ignore_subscribe_messages=ignore_subscribe_messages + ) if message is not None: return message time.sleep(0.01) @@ -29,39 +27,39 @@ def wait_for_message(pubsub, timeout=0.1, ignore_subscribe_messages=False): def make_message(type, channel, data, pattern=None): return { - 'type': type, - 'pattern': pattern and pattern.encode('utf-8') or None, - 'channel': channel and channel.encode('utf-8') or None, - 'data': data.encode('utf-8') if isinstance(data, str) else data + "type": type, + "pattern": pattern and pattern.encode("utf-8") or None, + "channel": channel and channel.encode("utf-8") or None, + "data": data.encode("utf-8") if isinstance(data, str) else data, } def make_subscribe_test_data(pubsub, type): - if type == 'channel': + if type == "channel": return { - 'p': pubsub, - 'sub_type': 'subscribe', - 'unsub_type': 'unsubscribe', - 'sub_func': pubsub.subscribe, - 'unsub_func': pubsub.unsubscribe, - 'keys': ['foo', 'bar', 'uni' + chr(4456) + 'code'] + "p": pubsub, + "sub_type": "subscribe", + "unsub_type": "unsubscribe", + "sub_func": pubsub.subscribe, + "unsub_func": pubsub.unsubscribe, + "keys": ["foo", "bar", "uni" + chr(4456) + "code"], } - elif type == 'pattern': + elif type == "pattern": return { - 'p': pubsub, - 'sub_type': 'psubscribe', - 'unsub_type': 'punsubscribe', - 'sub_func': pubsub.psubscribe, - 'unsub_func': pubsub.punsubscribe, - 'keys': ['f*', 'b*', 'uni' + chr(4456) + '*'] + "p": pubsub, + "sub_type": "psubscribe", + "unsub_type": "punsubscribe", + "sub_func": pubsub.psubscribe, + "unsub_func": pubsub.punsubscribe, + "keys": ["f*", "b*", "uni" + chr(4456) + "*"], } - assert False, f'invalid subscribe type: {type}' + assert False, f"invalid subscribe type: {type}" class TestPubSubSubscribeUnsubscribe: - - def _test_subscribe_unsubscribe(self, p, sub_type, unsub_type, sub_func, - unsub_func, keys): + def _test_subscribe_unsubscribe( + self, p, sub_type, unsub_type, sub_func, unsub_func, keys + ): for key in keys: assert sub_func(key) is None @@ -79,15 +77,16 @@ def _test_subscribe_unsubscribe(self, p, sub_type, unsub_type, sub_func, assert wait_for_message(p) == make_message(unsub_type, key, i) def test_channel_subscribe_unsubscribe(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'channel') + kwargs = make_subscribe_test_data(r.pubsub(), "channel") self._test_subscribe_unsubscribe(**kwargs) def test_pattern_subscribe_unsubscribe(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'pattern') + kwargs = make_subscribe_test_data(r.pubsub(), "pattern") self._test_subscribe_unsubscribe(**kwargs) - def _test_resubscribe_on_reconnection(self, p, sub_type, unsub_type, - sub_func, unsub_func, keys): + def _test_resubscribe_on_reconnection( + self, p, sub_type, unsub_type, sub_func, unsub_func, keys + ): for key in keys: assert sub_func(key) is None @@ -109,10 +108,10 @@ def _test_resubscribe_on_reconnection(self, p, sub_type, unsub_type, unique_channels = set() assert len(messages) == len(keys) for i, message in enumerate(messages): - assert message['type'] == sub_type - assert message['data'] == i + 1 - assert isinstance(message['channel'], bytes) - channel = message['channel'].decode('utf-8') + assert message["type"] == sub_type + assert message["data"] == i + 1 + assert isinstance(message["channel"], bytes) + channel = message["channel"].decode("utf-8") unique_channels.add(channel) assert len(unique_channels) == len(keys) @@ -120,16 +119,17 @@ def _test_resubscribe_on_reconnection(self, p, sub_type, unsub_type, assert channel in keys def test_resubscribe_to_channels_on_reconnection(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'channel') + kwargs = make_subscribe_test_data(r.pubsub(), "channel") self._test_resubscribe_on_reconnection(**kwargs) @pytest.mark.onlynoncluster def test_resubscribe_to_patterns_on_reconnection(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'pattern') + kwargs = make_subscribe_test_data(r.pubsub(), "pattern") self._test_resubscribe_on_reconnection(**kwargs) - def _test_subscribed_property(self, p, sub_type, unsub_type, sub_func, - unsub_func, keys): + def _test_subscribed_property( + self, p, sub_type, unsub_type, sub_func, unsub_func, keys + ): assert p.subscribed is False sub_func(keys[0]) @@ -175,22 +175,22 @@ def _test_subscribed_property(self, p, sub_type, unsub_type, sub_func, assert p.subscribed is False def test_subscribe_property_with_channels(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'channel') + kwargs = make_subscribe_test_data(r.pubsub(), "channel") self._test_subscribed_property(**kwargs) @pytest.mark.onlynoncluster def test_subscribe_property_with_patterns(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'pattern') + kwargs = make_subscribe_test_data(r.pubsub(), "pattern") self._test_subscribed_property(**kwargs) def test_ignore_all_subscribe_messages(self, r): p = r.pubsub(ignore_subscribe_messages=True) checks = ( - (p.subscribe, 'foo'), - (p.unsubscribe, 'foo'), - (p.psubscribe, 'f*'), - (p.punsubscribe, 'f*'), + (p.subscribe, "foo"), + (p.unsubscribe, "foo"), + (p.psubscribe, "f*"), + (p.punsubscribe, "f*"), ) assert p.subscribed is False @@ -204,10 +204,10 @@ def test_ignore_individual_subscribe_messages(self, r): p = r.pubsub() checks = ( - (p.subscribe, 'foo'), - (p.unsubscribe, 'foo'), - (p.psubscribe, 'f*'), - (p.punsubscribe, 'f*'), + (p.subscribe, "foo"), + (p.unsubscribe, "foo"), + (p.psubscribe, "f*"), + (p.punsubscribe, "f*"), ) assert p.subscribed is False @@ -219,16 +219,17 @@ def test_ignore_individual_subscribe_messages(self, r): assert p.subscribed is False def test_sub_unsub_resub_channels(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'channel') + kwargs = make_subscribe_test_data(r.pubsub(), "channel") self._test_sub_unsub_resub(**kwargs) @pytest.mark.onlynoncluster def test_sub_unsub_resub_patterns(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'pattern') + kwargs = make_subscribe_test_data(r.pubsub(), "pattern") self._test_sub_unsub_resub(**kwargs) - def _test_sub_unsub_resub(self, p, sub_type, unsub_type, sub_func, - unsub_func, keys): + def _test_sub_unsub_resub( + self, p, sub_type, unsub_type, sub_func, unsub_func, keys + ): # https://github.com/andymccurdy/redis-py/issues/764 key = keys[0] sub_func(key) @@ -241,15 +242,16 @@ def _test_sub_unsub_resub(self, p, sub_type, unsub_type, sub_func, assert p.subscribed is True def test_sub_unsub_all_resub_channels(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'channel') + kwargs = make_subscribe_test_data(r.pubsub(), "channel") self._test_sub_unsub_all_resub(**kwargs) def test_sub_unsub_all_resub_patterns(self, r): - kwargs = make_subscribe_test_data(r.pubsub(), 'pattern') + kwargs = make_subscribe_test_data(r.pubsub(), "pattern") self._test_sub_unsub_all_resub(**kwargs) - def _test_sub_unsub_all_resub(self, p, sub_type, unsub_type, sub_func, - unsub_func, keys): + def _test_sub_unsub_all_resub( + self, p, sub_type, unsub_type, sub_func, unsub_func, keys + ): # https://github.com/andymccurdy/redis-py/issues/764 key = keys[0] sub_func(key) @@ -271,22 +273,22 @@ def message_handler(self, message): def test_published_message_to_channel(self, r): p = r.pubsub() - p.subscribe('foo') - assert wait_for_message(p) == make_message('subscribe', 'foo', 1) - assert r.publish('foo', 'test message') == 1 + p.subscribe("foo") + assert wait_for_message(p) == make_message("subscribe", "foo", 1) + assert r.publish("foo", "test message") == 1 message = wait_for_message(p) assert isinstance(message, dict) - assert message == make_message('message', 'foo', 'test message') + assert message == make_message("message", "foo", "test message") def test_published_message_to_pattern(self, r): p = r.pubsub() - p.subscribe('foo') - p.psubscribe('f*') - assert wait_for_message(p) == make_message('subscribe', 'foo', 1) - assert wait_for_message(p) == make_message('psubscribe', 'f*', 2) + p.subscribe("foo") + p.psubscribe("f*") + assert wait_for_message(p) == make_message("subscribe", "foo", 1) + assert wait_for_message(p) == make_message("psubscribe", "f*", 2) # 1 to pattern, 1 to channel - assert r.publish('foo', 'test message') == 2 + assert r.publish("foo", "test message") == 2 message1 = wait_for_message(p) message2 = wait_for_message(p) @@ -294,8 +296,8 @@ def test_published_message_to_pattern(self, r): assert isinstance(message2, dict) expected = [ - make_message('message', 'foo', 'test message'), - make_message('pmessage', 'foo', 'test message', pattern='f*') + make_message("message", "foo", "test message"), + make_message("pmessage", "foo", "test message", pattern="f*"), ] assert message1 in expected @@ -306,67 +308,65 @@ def test_channel_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) p.subscribe(foo=self.message_handler) assert wait_for_message(p) is None - assert r.publish('foo', 'test message') == 1 + assert r.publish("foo", "test message") == 1 assert wait_for_message(p) is None - assert self.message == make_message('message', 'foo', 'test message') + assert self.message == make_message("message", "foo", "test message") @pytest.mark.onlynoncluster def test_pattern_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) - p.psubscribe(**{'f*': self.message_handler}) + p.psubscribe(**{"f*": self.message_handler}) assert wait_for_message(p) is None - assert r.publish('foo', 'test message') == 1 + assert r.publish("foo", "test message") == 1 assert wait_for_message(p) is None - assert self.message == make_message('pmessage', 'foo', 'test message', - pattern='f*') + assert self.message == make_message( + "pmessage", "foo", "test message", pattern="f*" + ) def test_unicode_channel_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) - channel = 'uni' + chr(4456) + 'code' + channel = "uni" + chr(4456) + "code" channels = {channel: self.message_handler} p.subscribe(**channels) assert wait_for_message(p) is None - assert r.publish(channel, 'test message') == 1 + assert r.publish(channel, "test message") == 1 assert wait_for_message(p) is None - assert self.message == make_message('message', channel, 'test message') + assert self.message == make_message("message", channel, "test message") @pytest.mark.onlynoncluster # see: https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html # #known-limitations-with-pubsub def test_unicode_pattern_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) - pattern = 'uni' + chr(4456) + '*' - channel = 'uni' + chr(4456) + 'code' + pattern = "uni" + chr(4456) + "*" + channel = "uni" + chr(4456) + "code" p.psubscribe(**{pattern: self.message_handler}) assert wait_for_message(p) is None - assert r.publish(channel, 'test message') == 1 + assert r.publish(channel, "test message") == 1 assert wait_for_message(p) is None - assert self.message == make_message('pmessage', channel, - 'test message', pattern=pattern) + assert self.message == make_message( + "pmessage", channel, "test message", pattern=pattern + ) def test_get_message_without_subscribe(self, r): p = r.pubsub() with pytest.raises(RuntimeError) as info: p.get_message() - expect = ('connection not set: ' - 'did you forget to call subscribe() or psubscribe()?') + expect = ( + "connection not set: " "did you forget to call subscribe() or psubscribe()?" + ) assert expect in info.exconly() class TestPubSubAutoDecoding: "These tests only validate that we get unicode values back" - channel = 'uni' + chr(4456) + 'code' - pattern = 'uni' + chr(4456) + '*' - data = 'abc' + chr(4458) + '123' + channel = "uni" + chr(4456) + "code" + pattern = "uni" + chr(4456) + "*" + data = "abc" + chr(4458) + "123" def make_message(self, type, channel, data, pattern=None): - return { - 'type': type, - 'channel': channel, - 'pattern': pattern, - 'data': data - } + return {"type": type, "channel": channel, "pattern": pattern, "data": data} def setup_method(self, method): self.message = None @@ -381,44 +381,37 @@ def r(self, request): def test_channel_subscribe_unsubscribe(self, r): p = r.pubsub() p.subscribe(self.channel) - assert wait_for_message(p) == self.make_message('subscribe', - self.channel, 1) + assert wait_for_message(p) == self.make_message("subscribe", self.channel, 1) p.unsubscribe(self.channel) - assert wait_for_message(p) == self.make_message('unsubscribe', - self.channel, 0) + assert wait_for_message(p) == self.make_message("unsubscribe", self.channel, 0) def test_pattern_subscribe_unsubscribe(self, r): p = r.pubsub() p.psubscribe(self.pattern) - assert wait_for_message(p) == self.make_message('psubscribe', - self.pattern, 1) + assert wait_for_message(p) == self.make_message("psubscribe", self.pattern, 1) p.punsubscribe(self.pattern) - assert wait_for_message(p) == self.make_message('punsubscribe', - self.pattern, 0) + assert wait_for_message(p) == self.make_message("punsubscribe", self.pattern, 0) def test_channel_publish(self, r): p = r.pubsub() p.subscribe(self.channel) - assert wait_for_message(p) == self.make_message('subscribe', - self.channel, 1) + assert wait_for_message(p) == self.make_message("subscribe", self.channel, 1) r.publish(self.channel, self.data) - assert wait_for_message(p) == self.make_message('message', - self.channel, - self.data) + assert wait_for_message(p) == self.make_message( + "message", self.channel, self.data + ) @pytest.mark.onlynoncluster def test_pattern_publish(self, r): p = r.pubsub() p.psubscribe(self.pattern) - assert wait_for_message(p) == self.make_message('psubscribe', - self.pattern, 1) + assert wait_for_message(p) == self.make_message("psubscribe", self.pattern, 1) r.publish(self.channel, self.data) - assert wait_for_message(p) == self.make_message('pmessage', - self.channel, - self.data, - pattern=self.pattern) + assert wait_for_message(p) == self.make_message( + "pmessage", self.channel, self.data, pattern=self.pattern + ) def test_channel_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) @@ -426,18 +419,16 @@ def test_channel_message_handler(self, r): assert wait_for_message(p) is None r.publish(self.channel, self.data) assert wait_for_message(p) is None - assert self.message == self.make_message('message', self.channel, - self.data) + assert self.message == self.make_message("message", self.channel, self.data) # test that we reconnected to the correct channel self.message = None p.connection.disconnect() assert wait_for_message(p) is None # should reconnect - new_data = self.data + 'new data' + new_data = self.data + "new data" r.publish(self.channel, new_data) assert wait_for_message(p) is None - assert self.message == self.make_message('message', self.channel, - new_data) + assert self.message == self.make_message("message", self.channel, new_data) def test_pattern_message_handler(self, r): p = r.pubsub(ignore_subscribe_messages=True) @@ -445,24 +436,24 @@ def test_pattern_message_handler(self, r): assert wait_for_message(p) is None r.publish(self.channel, self.data) assert wait_for_message(p) is None - assert self.message == self.make_message('pmessage', self.channel, - self.data, - pattern=self.pattern) + assert self.message == self.make_message( + "pmessage", self.channel, self.data, pattern=self.pattern + ) # test that we reconnected to the correct pattern self.message = None p.connection.disconnect() assert wait_for_message(p) is None # should reconnect - new_data = self.data + 'new data' + new_data = self.data + "new data" r.publish(self.channel, new_data) assert wait_for_message(p) is None - assert self.message == self.make_message('pmessage', self.channel, - new_data, - pattern=self.pattern) + assert self.message == self.make_message( + "pmessage", self.channel, new_data, pattern=self.pattern + ) def test_context_manager(self, r): with r.pubsub() as pubsub: - pubsub.subscribe('foo') + pubsub.subscribe("foo") assert pubsub.connection is not None assert pubsub.connection is None @@ -471,86 +462,82 @@ def test_context_manager(self, r): class TestPubSubRedisDown: - def test_channel_subscribe(self, r): - r = redis.Redis(host='localhost', port=6390) + r = redis.Redis(host="localhost", port=6390) p = r.pubsub() with pytest.raises(ConnectionError): - p.subscribe('foo') + p.subscribe("foo") class TestPubSubSubcommands: - @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_pubsub_channels(self, r): p = r.pubsub() - p.subscribe('foo', 'bar', 'baz', 'quux') + p.subscribe("foo", "bar", "baz", "quux") for i in range(4): - assert wait_for_message(p)['type'] == 'subscribe' - expected = [b'bar', b'baz', b'foo', b'quux'] + assert wait_for_message(p)["type"] == "subscribe" + expected = [b"bar", b"baz", b"foo", b"quux"] assert all([channel in r.pubsub_channels() for channel in expected]) @pytest.mark.onlynoncluster - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_pubsub_numsub(self, r): p1 = r.pubsub() - p1.subscribe('foo', 'bar', 'baz') + p1.subscribe("foo", "bar", "baz") for i in range(3): - assert wait_for_message(p1)['type'] == 'subscribe' + assert wait_for_message(p1)["type"] == "subscribe" p2 = r.pubsub() - p2.subscribe('bar', 'baz') + p2.subscribe("bar", "baz") for i in range(2): - assert wait_for_message(p2)['type'] == 'subscribe' + assert wait_for_message(p2)["type"] == "subscribe" p3 = r.pubsub() - p3.subscribe('baz') - assert wait_for_message(p3)['type'] == 'subscribe' + p3.subscribe("baz") + assert wait_for_message(p3)["type"] == "subscribe" - channels = [(b'foo', 1), (b'bar', 2), (b'baz', 3)] - assert r.pubsub_numsub('foo', 'bar', 'baz') == channels + channels = [(b"foo", 1), (b"bar", 2), (b"baz", 3)] + assert r.pubsub_numsub("foo", "bar", "baz") == channels - @skip_if_server_version_lt('2.8.0') + @skip_if_server_version_lt("2.8.0") def test_pubsub_numpat(self, r): p = r.pubsub() - p.psubscribe('*oo', '*ar', 'b*z') + p.psubscribe("*oo", "*ar", "b*z") for i in range(3): - assert wait_for_message(p)['type'] == 'psubscribe' + assert wait_for_message(p)["type"] == "psubscribe" assert r.pubsub_numpat() == 3 class TestPubSubPings: - - @skip_if_server_version_lt('3.0.0') + @skip_if_server_version_lt("3.0.0") def test_send_pubsub_ping(self, r): p = r.pubsub(ignore_subscribe_messages=True) - p.subscribe('foo') + p.subscribe("foo") p.ping() - assert wait_for_message(p) == make_message(type='pong', channel=None, - data='', - pattern=None) + assert wait_for_message(p) == make_message( + type="pong", channel=None, data="", pattern=None + ) - @skip_if_server_version_lt('3.0.0') + @skip_if_server_version_lt("3.0.0") def test_send_pubsub_ping_message(self, r): p = r.pubsub(ignore_subscribe_messages=True) - p.subscribe('foo') - p.ping(message='hello world') - assert wait_for_message(p) == make_message(type='pong', channel=None, - data='hello world', - pattern=None) + p.subscribe("foo") + p.ping(message="hello world") + assert wait_for_message(p) == make_message( + type="pong", channel=None, data="hello world", pattern=None + ) @pytest.mark.onlynoncluster class TestPubSubConnectionKilled: - - @skip_if_server_version_lt('3.0.0') + @skip_if_server_version_lt("3.0.0") @skip_if_redis_enterprise def test_connection_error_raised_when_connection_dies(self, r): p = r.pubsub() - p.subscribe('foo') - assert wait_for_message(p) == make_message('subscribe', 'foo', 1) + p.subscribe("foo") + assert wait_for_message(p) == make_message("subscribe", "foo", 1) for client in r.client_list(): - if client['cmd'] == 'subscribe': - r.client_kill_filter(_id=client['id']) + if client["cmd"] == "subscribe": + r.client_kill_filter(_id=client["id"]) with pytest.raises(ConnectionError): wait_for_message(p) @@ -558,15 +545,15 @@ def test_connection_error_raised_when_connection_dies(self, r): class TestPubSubTimeouts: def test_get_message_with_timeout_returns_none(self, r): p = r.pubsub() - p.subscribe('foo') - assert wait_for_message(p) == make_message('subscribe', 'foo', 1) + p.subscribe("foo") + assert wait_for_message(p) == make_message("subscribe", "foo", 1) assert p.get_message(timeout=0.01) is None class TestPubSubWorkerThread: - - @pytest.mark.skipif(platform.python_implementation() == 'PyPy', - reason="Pypy threading issue") + @pytest.mark.skipif( + platform.python_implementation() == "PyPy", reason="Pypy threading issue" + ) def test_pubsub_worker_thread_exception_handler(self, r): event = threading.Event() @@ -575,12 +562,10 @@ def exception_handler(ex, pubsub, thread): event.set() p = r.pubsub() - p.subscribe(**{'foo': lambda m: m}) - with mock.patch.object(p, 'get_message', - side_effect=Exception('error')): + p.subscribe(**{"foo": lambda m: m}) + with mock.patch.object(p, "get_message", side_effect=Exception("error")): pubsub_thread = p.run_in_thread( - daemon=True, - exception_handler=exception_handler + daemon=True, exception_handler=exception_handler ) assert event.wait(timeout=1.0) @@ -589,10 +574,9 @@ def exception_handler(ex, pubsub, thread): class TestPubSubDeadlock: - @pytest.mark.timeout(30, method='thread') + @pytest.mark.timeout(30, method="thread") def test_pubsub_deadlock(self, master_host): - pool = redis.ConnectionPool(host=master_host[0], - port=master_host[1]) + pool = redis.ConnectionPool(host=master_host[0], port=master_host[1]) r = redis.Redis(connection_pool=pool) for i in range(60): diff --git a/tests/test_retry.py b/tests/test_retry.py index 535485acae..c4650bc650 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -1,8 +1,8 @@ -from redis.backoff import NoBackoff import pytest -from redis.exceptions import ConnectionError +from redis.backoff import NoBackoff from redis.connection import Connection, UnixDomainSocketConnection +from redis.exceptions import ConnectionError from redis.retry import Retry @@ -34,8 +34,7 @@ def test_retry_on_timeout_boolean(self, Class, retry_on_timeout): @pytest.mark.parametrize("Class", [Connection, UnixDomainSocketConnection]) def test_retry_on_timeout_retry(self, Class, retries): retry_on_timeout = retries > 0 - c = Class(retry_on_timeout=retry_on_timeout, - retry=Retry(NoBackoff(), retries)) + c = Class(retry_on_timeout=retry_on_timeout, retry=Retry(NoBackoff(), retries)) assert c.retry_on_timeout == retry_on_timeout assert isinstance(c.retry, Retry) assert c.retry._retries == retries diff --git a/tests/test_scripting.py b/tests/test_scripting.py index 7614b1233f..9f4f82023f 100644 --- a/tests/test_scripting.py +++ b/tests/test_scripting.py @@ -1,10 +1,8 @@ import pytest from redis import exceptions - from tests.conftest import skip_if_server_version_lt - multiply_script = """ local value = redis.call('GET', KEYS[1]) value = tonumber(value) @@ -29,52 +27,52 @@ def reset_scripts(self, r): r.script_flush() def test_eval(self, r): - r.set('a', 2) + r.set("a", 2) # 2 * 3 == 6 - assert r.eval(multiply_script, 1, 'a', 3) == 6 + assert r.eval(multiply_script, 1, "a", 3) == 6 - @skip_if_server_version_lt('6.2.0') + @skip_if_server_version_lt("6.2.0") def test_script_flush_620(self, r): - r.set('a', 2) + r.set("a", 2) r.script_load(multiply_script) - r.script_flush('ASYNC') + r.script_flush("ASYNC") - r.set('a', 2) + r.set("a", 2) r.script_load(multiply_script) - r.script_flush('SYNC') + r.script_flush("SYNC") - r.set('a', 2) + r.set("a", 2) r.script_load(multiply_script) r.script_flush() with pytest.raises(exceptions.DataError): - r.set('a', 2) + r.set("a", 2) r.script_load(multiply_script) r.script_flush("NOTREAL") def test_script_flush(self, r): - r.set('a', 2) + r.set("a", 2) r.script_load(multiply_script) r.script_flush(None) with pytest.raises(exceptions.DataError): - r.set('a', 2) + r.set("a", 2) r.script_load(multiply_script) r.script_flush("NOTREAL") def test_evalsha(self, r): - r.set('a', 2) + r.set("a", 2) sha = r.script_load(multiply_script) # 2 * 3 == 6 - assert r.evalsha(sha, 1, 'a', 3) == 6 + assert r.evalsha(sha, 1, "a", 3) == 6 def test_evalsha_script_not_loaded(self, r): - r.set('a', 2) + r.set("a", 2) sha = r.script_load(multiply_script) # remove the script from Redis's cache r.script_flush() with pytest.raises(exceptions.NoScriptError): - r.evalsha(sha, 1, 'a', 3) + r.evalsha(sha, 1, "a", 3) def test_script_loading(self, r): # get the sha, then clear the cache @@ -85,31 +83,31 @@ def test_script_loading(self, r): assert r.script_exists(sha) == [True] def test_script_object(self, r): - r.set('a', 2) + r.set("a", 2) multiply = r.register_script(multiply_script) precalculated_sha = multiply.sha assert precalculated_sha assert r.script_exists(multiply.sha) == [False] # Test second evalsha block (after NoScriptError) - assert multiply(keys=['a'], args=[3]) == 6 + assert multiply(keys=["a"], args=[3]) == 6 # At this point, the script should be loaded assert r.script_exists(multiply.sha) == [True] # Test that the precalculated sha matches the one from redis assert multiply.sha == precalculated_sha # Test first evalsha block - assert multiply(keys=['a'], args=[3]) == 6 + assert multiply(keys=["a"], args=[3]) == 6 def test_script_object_in_pipeline(self, r): multiply = r.register_script(multiply_script) precalculated_sha = multiply.sha assert precalculated_sha pipe = r.pipeline() - pipe.set('a', 2) - pipe.get('a') - multiply(keys=['a'], args=[3], client=pipe) + pipe.set("a", 2) + pipe.get("a") + multiply(keys=["a"], args=[3], client=pipe) assert r.script_exists(multiply.sha) == [False] # [SET worked, GET 'a', result of multiple script] - assert pipe.execute() == [True, b'2', 6] + assert pipe.execute() == [True, b"2", 6] # The script should have been loaded by pipe.execute() assert r.script_exists(multiply.sha) == [True] # The precalculated sha should have been the correct one @@ -119,12 +117,12 @@ def test_script_object_in_pipeline(self, r): # the multiply script should be reloaded by pipe.execute() r.script_flush() pipe = r.pipeline() - pipe.set('a', 2) - pipe.get('a') - multiply(keys=['a'], args=[3], client=pipe) + pipe.set("a", 2) + pipe.get("a") + multiply(keys=["a"], args=[3], client=pipe) assert r.script_exists(multiply.sha) == [False] # [SET worked, GET 'a', result of multiple script] - assert pipe.execute() == [True, b'2', 6] + assert pipe.execute() == [True, b"2", 6] assert r.script_exists(multiply.sha) == [True] def test_eval_msgpack_pipeline_error_in_lua(self, r): @@ -135,12 +133,12 @@ def test_eval_msgpack_pipeline_error_in_lua(self, r): # avoiding a dependency to msgpack, this is the output of # msgpack.dumps({"name": "joe"}) - msgpack_message_1 = b'\x81\xa4name\xa3Joe' + msgpack_message_1 = b"\x81\xa4name\xa3Joe" msgpack_hello(args=[msgpack_message_1], client=pipe) assert r.script_exists(msgpack_hello.sha) == [False] - assert pipe.execute()[0] == b'hello Joe' + assert pipe.execute()[0] == b"hello Joe" assert r.script_exists(msgpack_hello.sha) == [True] msgpack_hello_broken = r.register_script(msgpack_hello_script_broken) diff --git a/tests/test_search.py b/tests/test_search.py index c7b570cdd1..5b6a66009a 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,52 +1,32 @@ -import pytest -import redis import bz2 import csv -import time import os - +import time from io import TextIOWrapper -from .conftest import skip_ifmodversion_lt, default_redismod_url -from redis import Redis +import pytest + +import redis import redis.commands.search +import redis.commands.search.aggregation as aggregations +import redis.commands.search.reducers as reducers +from redis import Redis from redis.commands.json.path import Path from redis.commands.search import Search -from redis.commands.search.field import ( - GeoField, - NumericField, - TagField, - TextField -) -from redis.commands.search.query import ( - GeoFilter, - NumericFilter, - Query -) -from redis.commands.search.result import Result +from redis.commands.search.field import GeoField, NumericField, TagField, TextField from redis.commands.search.indexDefinition import IndexDefinition, IndexType +from redis.commands.search.query import GeoFilter, NumericFilter, Query +from redis.commands.search.result import Result from redis.commands.search.suggestion import Suggestion -import redis.commands.search.aggregation as aggregations -import redis.commands.search.reducers as reducers -WILL_PLAY_TEXT = ( - os.path.abspath( - os.path.join( - os.path.dirname(__file__), - "testdata", - "will_play_text.csv.bz2" - ) - ) +from .conftest import default_redismod_url, skip_ifmodversion_lt + +WILL_PLAY_TEXT = os.path.abspath( + os.path.join(os.path.dirname(__file__), "testdata", "will_play_text.csv.bz2") ) -TITLES_CSV = ( - os.path.abspath( - os.path.join( - os.path.dirname(__file__), - "testdata", - "titles.csv" - ) - ) +TITLES_CSV = os.path.abspath( + os.path.join(os.path.dirname(__file__), "testdata", "titles.csv") ) @@ -81,9 +61,7 @@ def getClient(): def createIndex(client, num_docs=100, definition=None): try: client.create_index( - (TextField("play", weight=5.0), - TextField("txt"), - NumericField("chapter")), + (TextField("play", weight=5.0), TextField("txt"), NumericField("chapter")), definition=definition, ) except redis.ResponseError: @@ -96,8 +74,7 @@ def createIndex(client, num_docs=100, definition=None): r = csv.reader(bzfp, delimiter=";") for n, line in enumerate(r): - play, chapter, _, text = \ - line[1], line[2], line[4], line[5] + play, chapter, _, text = line[1], line[2], line[4], line[5] key = f"{play}:{chapter}".lower() d = chapters.setdefault(key, {}) @@ -183,12 +160,10 @@ def test_client(client): # test in fields txt_total = ( - client.ft().search( - Query("henry").no_content().limit_fields("txt")).total + client.ft().search(Query("henry").no_content().limit_fields("txt")).total ) play_total = ( - client.ft().search( - Query("henry").no_content().limit_fields("play")).total + client.ft().search(Query("henry").no_content().limit_fields("play")).total ) both_total = ( client.ft() @@ -217,10 +192,8 @@ def test_client(client): # test slop and in order assert 193 == client.ft().search(Query("henry king")).total - assert 3 == client.ft().search( - Query("henry king").slop(0).in_order()).total - assert 52 == client.ft().search( - Query("king henry").slop(0).in_order()).total + assert 3 == client.ft().search(Query("henry king").slop(0).in_order()).total + assert 52 == client.ft().search(Query("king henry").slop(0).in_order()).total assert 53 == client.ft().search(Query("henry king").slop(0)).total assert 167 == client.ft().search(Query("henry king").slop(100)).total @@ -284,11 +257,7 @@ def test_replace(client): res = client.ft().search("foo bar") assert 2 == res.total - client.ft().add_document( - "doc1", - replace=True, - txt="this is a replaced doc" - ) + client.ft().add_document("doc1", replace=True, txt="this is a replaced doc") res = client.ft().search("foo bar") assert 1 == res.total @@ -301,10 +270,7 @@ def test_replace(client): @pytest.mark.redismod def test_stopwords(client): - client.ft().create_index( - (TextField("txt"),), - stopwords=["foo", "bar", "baz"] - ) + client.ft().create_index((TextField("txt"),), stopwords=["foo", "bar", "baz"]) client.ft().add_document("doc1", txt="foo bar") client.ft().add_document("doc2", txt="hello world") waitForIndex(client, "idx") @@ -318,17 +284,8 @@ def test_stopwords(client): @pytest.mark.redismod def test_filters(client): - client.ft().create_index( - (TextField("txt"), - NumericField("num"), - GeoField("loc")) - ) - client.ft().add_document( - "doc1", - txt="foo bar", - num=3.141, - loc="-0.441,51.458" - ) + client.ft().create_index((TextField("txt"), NumericField("num"), GeoField("loc"))) + client.ft().add_document("doc1", txt="foo bar", num=3.141, loc="-0.441,51.458") client.ft().add_document("doc2", txt="foo baz", num=2, loc="-0.1,51.2") waitForIndex(client, "idx") @@ -336,8 +293,7 @@ def test_filters(client): q1 = Query("foo").add_filter(NumericFilter("num", 0, 2)).no_content() q2 = ( Query("foo") - .add_filter( - NumericFilter("num", 2, NumericFilter.INF, minExclusive=True)) + .add_filter(NumericFilter("num", 2, NumericFilter.INF, minExclusive=True)) .no_content() ) res1, res2 = client.ft().search(q1), client.ft().search(q2) @@ -348,10 +304,8 @@ def test_filters(client): assert "doc1" == res2.docs[0].id # Test geo filter - q1 = Query("foo").add_filter( - GeoFilter("loc", -0.44, 51.45, 10)).no_content() - q2 = Query("foo").add_filter( - GeoFilter("loc", -0.44, 51.45, 100)).no_content() + q1 = Query("foo").add_filter(GeoFilter("loc", -0.44, 51.45, 10)).no_content() + q2 = Query("foo").add_filter(GeoFilter("loc", -0.44, 51.45, 100)).no_content() res1, res2 = client.ft().search(q1), client.ft().search(q2) assert 1 == res1.total @@ -377,10 +331,7 @@ def test_payloads_with_no_content(client): @pytest.mark.redismod def test_sort_by(client): - client.ft().create_index( - (TextField("txt"), - NumericField("num", sortable=True)) - ) + client.ft().create_index((TextField("txt"), NumericField("num", sortable=True))) client.ft().add_document("doc1", txt="foo bar", num=1) client.ft().add_document("doc2", txt="foo baz", num=2) client.ft().add_document("doc3", txt="foo qux", num=3) @@ -422,10 +373,7 @@ def test_drop_index(): @pytest.mark.redismod def test_example(client): # Creating the index definition and schema - client.ft().create_index( - (TextField("title", weight=5.0), - TextField("body")) - ) + client.ft().create_index((TextField("title", weight=5.0), TextField("body"))) # Indexing a document client.ft().add_document( @@ -483,12 +431,7 @@ def test_auto_complete(client): client.ft().sugadd("ac", Suggestion("pay2", payload="pl2")) client.ft().sugadd("ac", Suggestion("pay3", payload="pl3")) - sugs = client.ft().sugget( - "ac", - "pay", - with_payloads=True, - with_scores=True - ) + sugs = client.ft().sugget("ac", "pay", with_payloads=True, with_scores=True) assert 3 == len(sugs) for sug in sugs: assert sug.payload @@ -550,11 +493,7 @@ def test_no_index(client): @pytest.mark.redismod def test_partial(client): - client.ft().create_index( - (TextField("f1"), - TextField("f2"), - TextField("f3")) - ) + client.ft().create_index((TextField("f1"), TextField("f2"), TextField("f3"))) client.ft().add_document("doc1", f1="f1_val", f2="f2_val") client.ft().add_document("doc2", f1="f1_val", f2="f2_val") client.ft().add_document("doc1", f3="f3_val", partial=True) @@ -572,11 +511,7 @@ def test_partial(client): @pytest.mark.redismod def test_no_create(client): - client.ft().create_index( - (TextField("f1"), - TextField("f2"), - TextField("f3")) - ) + client.ft().create_index((TextField("f1"), TextField("f2"), TextField("f3"))) client.ft().add_document("doc1", f1="f1_val", f2="f2_val") client.ft().add_document("doc2", f1="f1_val", f2="f2_val") client.ft().add_document("doc1", f3="f3_val", no_create=True) @@ -592,21 +527,12 @@ def test_no_create(client): assert 1 == res.total with pytest.raises(redis.ResponseError): - client.ft().add_document( - "doc3", - f2="f2_val", - f3="f3_val", - no_create=True - ) + client.ft().add_document("doc3", f2="f2_val", f3="f3_val", no_create=True) @pytest.mark.redismod def test_explain(client): - client.ft().create_index( - (TextField("f1"), - TextField("f2"), - TextField("f3")) - ) + client.ft().create_index((TextField("f1"), TextField("f2"), TextField("f3"))) res = client.ft().explain("@f3:f3_val @f2:f2_val @f1:f1_val") assert res @@ -629,8 +555,8 @@ def test_summarize(client): doc = sorted(client.ft().search(q).docs)[0] assert "Henry IV" == doc.play assert ( - "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa - == doc.txt + "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa + == doc.txt ) q = Query("king henry").paging(0, 1).summarize().highlight() @@ -638,8 +564,8 @@ def test_summarize(client): doc = sorted(client.ft().search(q).docs)[0] assert "Henry ... " == doc.play assert ( - "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa - == doc.txt + "ACT I SCENE I. London. The palace. Enter KING HENRY, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... " # noqa + == doc.txt ) @@ -786,11 +712,7 @@ def test_alter_schema_add(client): def test_spell_check(client): client.ft().create_index((TextField("f1"), TextField("f2"))) - client.ft().add_document( - "doc1", - f1="some valid content", - f2="this is sample text" - ) + client.ft().add_document("doc1", f1="some valid content", f2="this is sample text") client.ft().add_document("doc2", f1="very important", f2="lorem ipsum") waitForIndex(client, "idx") @@ -812,10 +734,10 @@ def test_spell_check(client): res = client.ft().spellcheck("lorm", include="dict") assert len(res["lorm"]) == 3 assert ( - res["lorm"][0]["suggestion"], - res["lorm"][1]["suggestion"], - res["lorm"][2]["suggestion"], - ) == ("lorem", "lore", "lorm") + res["lorm"][0]["suggestion"], + res["lorm"][1]["suggestion"], + res["lorm"][2]["suggestion"], + ) == ("lorem", "lore", "lorm") assert (res["lorm"][0]["score"], res["lorm"][1]["score"]) == ("0.5", "0") # test spellcheck exclude @@ -873,7 +795,7 @@ def test_scorer(client): ) client.ft().add_document( "doc2", - description="Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.", # noqa + description="Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.", # noqa ) # default scorer is TFIDF @@ -881,8 +803,7 @@ def test_scorer(client): assert 1.0 == res.docs[0].score res = client.ft().search(Query("quick").scorer("TFIDF").with_scores()) assert 1.0 == res.docs[0].score - res = client.ft().search( - Query("quick").scorer("TFIDF.DOCNORM").with_scores()) + res = client.ft().search(Query("quick").scorer("TFIDF.DOCNORM").with_scores()) assert 0.1111111111111111 == res.docs[0].score res = client.ft().search(Query("quick").scorer("BM25").with_scores()) assert 0.17699114465425977 == res.docs[0].score @@ -1060,7 +981,7 @@ def test_aggregations_groupby(client): ) res = client.ft().aggregate(req).rows[0] - assert res == ['parent', 'redis', 'first', 'RediSearch'] + assert res == ["parent", "redis", "first", "RediSearch"] req = aggregations.AggregateRequest("redis").group_by( "@parent", @@ -1083,35 +1004,33 @@ def test_aggregations_sort_by_and_limit(client): ) ) - client.ft().client.hset("doc1", mapping={'t1': 'a', 't2': 'b'}) - client.ft().client.hset("doc2", mapping={'t1': 'b', 't2': 'a'}) + client.ft().client.hset("doc1", mapping={"t1": "a", "t2": "b"}) + client.ft().client.hset("doc2", mapping={"t1": "b", "t2": "a"}) # test sort_by using SortDirection - req = aggregations.AggregateRequest("*") \ - .sort_by(aggregations.Asc("@t2"), aggregations.Desc("@t1")) + req = aggregations.AggregateRequest("*").sort_by( + aggregations.Asc("@t2"), aggregations.Desc("@t1") + ) res = client.ft().aggregate(req) - assert res.rows[0] == ['t2', 'a', 't1', 'b'] - assert res.rows[1] == ['t2', 'b', 't1', 'a'] + assert res.rows[0] == ["t2", "a", "t1", "b"] + assert res.rows[1] == ["t2", "b", "t1", "a"] # test sort_by without SortDirection - req = aggregations.AggregateRequest("*") \ - .sort_by("@t1") + req = aggregations.AggregateRequest("*").sort_by("@t1") res = client.ft().aggregate(req) - assert res.rows[0] == ['t1', 'a'] - assert res.rows[1] == ['t1', 'b'] + assert res.rows[0] == ["t1", "a"] + assert res.rows[1] == ["t1", "b"] # test sort_by with max - req = aggregations.AggregateRequest("*") \ - .sort_by("@t1", max=1) + req = aggregations.AggregateRequest("*").sort_by("@t1", max=1) res = client.ft().aggregate(req) assert len(res.rows) == 1 # test limit - req = aggregations.AggregateRequest("*") \ - .sort_by("@t1").limit(1, 1) + req = aggregations.AggregateRequest("*").sort_by("@t1").limit(1, 1) res = client.ft().aggregate(req) assert len(res.rows) == 1 - assert res.rows[0] == ['t1', 'b'] + assert res.rows[0] == ["t1", "b"] @pytest.mark.redismod @@ -1123,17 +1042,17 @@ def test_aggregations_load(client): ) ) - client.ft().client.hset("doc1", mapping={'t1': 'hello', 't2': 'world'}) + client.ft().client.hset("doc1", mapping={"t1": "hello", "t2": "world"}) # load t1 req = aggregations.AggregateRequest("*").load("t1") res = client.ft().aggregate(req) - assert res.rows[0] == ['t1', 'hello'] + assert res.rows[0] == ["t1", "hello"] # load t2 req = aggregations.AggregateRequest("*").load("t2") res = client.ft().aggregate(req) - assert res.rows[0] == ['t2', 'world'] + assert res.rows[0] == ["t2", "world"] @pytest.mark.redismod @@ -1147,24 +1066,19 @@ def test_aggregations_apply(client): client.ft().client.hset( "doc1", - mapping={ - 'PrimaryKey': '9::362330', - 'CreatedDateTimeUTC': '637387878524969984' - } + mapping={"PrimaryKey": "9::362330", "CreatedDateTimeUTC": "637387878524969984"}, ) client.ft().client.hset( "doc2", - mapping={ - 'PrimaryKey': '9::362329', - 'CreatedDateTimeUTC': '637387875859270016' - } + mapping={"PrimaryKey": "9::362329", "CreatedDateTimeUTC": "637387875859270016"}, ) - req = aggregations.AggregateRequest("*") \ - .apply(CreatedDateTimeUTC='@CreatedDateTimeUTC * 10') + req = aggregations.AggregateRequest("*").apply( + CreatedDateTimeUTC="@CreatedDateTimeUTC * 10" + ) res = client.ft().aggregate(req) - assert res.rows[0] == ['CreatedDateTimeUTC', '6373878785249699840'] - assert res.rows[1] == ['CreatedDateTimeUTC', '6373878758592700416'] + assert res.rows[0] == ["CreatedDateTimeUTC", "6373878785249699840"] + assert res.rows[1] == ["CreatedDateTimeUTC", "6373878758592700416"] @pytest.mark.redismod @@ -1176,33 +1090,19 @@ def test_aggregations_filter(client): ) ) - client.ft().client.hset( - "doc1", - mapping={ - 'name': 'bar', - 'age': '25' - } - ) - client.ft().client.hset( - "doc2", - mapping={ - 'name': 'foo', - 'age': '19' - } - ) + client.ft().client.hset("doc1", mapping={"name": "bar", "age": "25"}) + client.ft().client.hset("doc2", mapping={"name": "foo", "age": "19"}) - req = aggregations.AggregateRequest("*") \ - .filter("@name=='foo' && @age < 20") + req = aggregations.AggregateRequest("*").filter("@name=='foo' && @age < 20") res = client.ft().aggregate(req) assert len(res.rows) == 1 - assert res.rows[0] == ['name', 'foo', 'age', '19'] + assert res.rows[0] == ["name", "foo", "age", "19"] - req = aggregations.AggregateRequest("*") \ - .filter("@age > 15").sort_by("@age") + req = aggregations.AggregateRequest("*").filter("@age > 15").sort_by("@age") res = client.ft().aggregate(req) assert len(res.rows) == 2 - assert res.rows[0] == ['age', '19'] - assert res.rows[1] == ['age', '25'] + assert res.rows[0] == ["age", "19"] + assert res.rows[1] == ["age", "25"] @pytest.mark.redismod @@ -1226,25 +1126,25 @@ def test_index_definition(client): ) assert [ - "ON", - "JSON", - "PREFIX", - 2, - "hset:", - "henry", - "FILTER", - "@f1==32", - "LANGUAGE_FIELD", - "play", - "LANGUAGE", - "English", - "SCORE_FIELD", - "chapter", - "SCORE", - 0.5, - "PAYLOAD_FIELD", - "txt", - ] == definition.args + "ON", + "JSON", + "PREFIX", + 2, + "hset:", + "henry", + "FILTER", + "@f1==32", + "LANGUAGE_FIELD", + "play", + "LANGUAGE", + "English", + "SCORE_FIELD", + "chapter", + "SCORE", + 0.5, + "PAYLOAD_FIELD", + "txt", + ] == definition.args createIndex(client.ft(), num_docs=500, definition=definition) @@ -1274,10 +1174,7 @@ def test_create_client_definition_hash(client): Create definition with IndexType.HASH as index type (ON HASH), and use hset to test the client definition. """ - definition = IndexDefinition( - prefix=["hset:", "henry"], - index_type=IndexType.HASH - ) + definition = IndexDefinition(prefix=["hset:", "henry"], index_type=IndexType.HASH) createIndex(client.ft(), num_docs=500, definition=definition) info = client.ft().info() @@ -1320,15 +1217,10 @@ def test_fields_as_name(client): client.ft().create_index(SCHEMA, definition=definition) # insert json data - res = client.json().set( - "doc:1", - Path.rootPath(), - {"name": "Jon", "age": 25} - ) + res = client.json().set("doc:1", Path.rootPath(), {"name": "Jon", "age": 25}) assert res - total = client.ft().search( - Query("Jon").return_fields("name", "just_a_number")).docs + total = client.ft().search(Query("Jon").return_fields("name", "just_a_number")).docs assert 1 == len(total) assert "doc:1" == total[0].id assert "Jon" == total[0].name @@ -1354,14 +1246,12 @@ def test_search_return_fields(client): client.ft().create_index(SCHEMA, definition=definition) waitForIndex(client, "idx") - total = client.ft().search( - Query("*").return_field("$.t", as_field="txt")).docs + total = client.ft().search(Query("*").return_field("$.t", as_field="txt")).docs assert 1 == len(total) assert "doc:1" == total[0].id assert "riceratops" == total[0].txt - total = client.ft().search( - Query("*").return_field("$.t2", as_field="txt")).docs + total = client.ft().search(Query("*").return_field("$.t2", as_field="txt")).docs assert 1 == len(total) assert "doc:1" == total[0].id assert "telmatosaurus" == total[0].txt @@ -1379,17 +1269,10 @@ def test_synupdate(client): ) client.ft().synupdate("id1", True, "boy", "child", "offspring") - client.ft().add_document( - "doc1", - title="he is a baby", - body="this is a test") + client.ft().add_document("doc1", title="he is a baby", body="this is a test") client.ft().synupdate("id1", True, "baby") - client.ft().add_document( - "doc2", - title="he is another baby", - body="another test" - ) + client.ft().add_document("doc2", title="he is another baby", body="another test") res = client.ft().search(Query("child").expander("SYNONYM")) assert res.docs[0].id == "doc2" @@ -1431,15 +1314,12 @@ def test_create_json_with_alias(client): """ definition = IndexDefinition(prefix=["king:"], index_type=IndexType.JSON) client.ft().create_index( - (TextField("$.name", as_name="name"), - NumericField("$.num", as_name="num")), - definition=definition + (TextField("$.name", as_name="name"), NumericField("$.num", as_name="num")), + definition=definition, ) - client.json().set("king:1", Path.rootPath(), {"name": "henry", - "num": 42}) - client.json().set("king:2", Path.rootPath(), {"name": "james", - "num": 3.14}) + client.json().set("king:1", Path.rootPath(), {"name": "henry", "num": 42}) + client.json().set("king:2", Path.rootPath(), {"name": "james", "num": 3.14}) res = client.ft().search("@name:henry") assert res.docs[0].id == "king:1" @@ -1466,12 +1346,12 @@ def test_json_with_multipath(client): """ definition = IndexDefinition(prefix=["king:"], index_type=IndexType.JSON) client.ft().create_index( - (TagField("$..name", as_name="name")), - definition=definition + (TagField("$..name", as_name="name")), definition=definition ) - client.json().set("king:1", Path.rootPath(), - {"name": "henry", "country": {"name": "england"}}) + client.json().set( + "king:1", Path.rootPath(), {"name": "henry", "country": {"name": "england"}} + ) res = client.ft().search("@name:{henry}") assert res.docs[0].id == "king:1" @@ -1489,9 +1369,11 @@ def test_json_with_multipath(client): def test_json_with_jsonpath(client): definition = IndexDefinition(index_type=IndexType.JSON) client.ft().create_index( - (TextField('$["prod:name"]', as_name="name"), - TextField('$.prod:name', as_name="name_unsupported")), - definition=definition + ( + TextField('$["prod:name"]', as_name="name"), + TextField("$.prod:name", as_name="name_unsupported"), + ), + definition=definition, ) client.json().set("doc:1", Path.rootPath(), {"prod:name": "RediSearch"}) @@ -1510,11 +1392,10 @@ def test_json_with_jsonpath(client): res = client.ft().search(Query("@name:RediSearch").return_field("name")) assert res.total == 1 assert res.docs[0].id == "doc:1" - assert res.docs[0].name == 'RediSearch' + assert res.docs[0].name == "RediSearch" # return of an unsupported field fails - res = client.ft().search(Query("@name:RediSearch") - .return_field("name_unsupported")) + res = client.ft().search(Query("@name:RediSearch").return_field("name_unsupported")) assert res.total == 1 assert res.docs[0].id == "doc:1" with pytest.raises(Exception): @@ -1523,42 +1404,49 @@ def test_json_with_jsonpath(client): @pytest.mark.redismod def test_profile(client): - client.ft().create_index((TextField('t'),)) - client.ft().client.hset('1', 't', 'hello') - client.ft().client.hset('2', 't', 'world') + client.ft().create_index((TextField("t"),)) + client.ft().client.hset("1", "t", "hello") + client.ft().client.hset("2", "t", "world") # check using Query - q = Query('hello|world').no_content() + q = Query("hello|world").no_content() res, det = client.ft().profile(q) - assert det['Iterators profile']['Counter'] == 2.0 - assert len(det['Iterators profile']['Child iterators']) == 2 - assert det['Iterators profile']['Type'] == 'UNION' - assert det['Parsing time'] < 0.3 + assert det["Iterators profile"]["Counter"] == 2.0 + assert len(det["Iterators profile"]["Child iterators"]) == 2 + assert det["Iterators profile"]["Type"] == "UNION" + assert det["Parsing time"] < 0.3 assert len(res.docs) == 2 # check also the search result # check using AggregateRequest - req = aggregations.AggregateRequest("*").load("t")\ + req = ( + aggregations.AggregateRequest("*") + .load("t") .apply(prefix="startswith(@t, 'hel')") + ) res, det = client.ft().profile(req) - assert det['Iterators profile']['Counter'] == 2.0 - assert det['Iterators profile']['Type'] == 'WILDCARD' - assert det['Parsing time'] < 0.3 + assert det["Iterators profile"]["Counter"] == 2.0 + assert det["Iterators profile"]["Type"] == "WILDCARD" + assert det["Parsing time"] < 0.3 assert len(res.rows) == 2 # check also the search result @pytest.mark.redismod def test_profile_limited(client): - client.ft().create_index((TextField('t'),)) - client.ft().client.hset('1', 't', 'hello') - client.ft().client.hset('2', 't', 'hell') - client.ft().client.hset('3', 't', 'help') - client.ft().client.hset('4', 't', 'helowa') + client.ft().create_index((TextField("t"),)) + client.ft().client.hset("1", "t", "hello") + client.ft().client.hset("2", "t", "hell") + client.ft().client.hset("3", "t", "help") + client.ft().client.hset("4", "t", "helowa") - q = Query('%hell% hel*') + q = Query("%hell% hel*") res, det = client.ft().profile(q, limited=True) - assert det['Iterators profile']['Child iterators'][0]['Child iterators'] \ - == 'The number of iterators in the union is 3' - assert det['Iterators profile']['Child iterators'][1]['Child iterators'] \ - == 'The number of iterators in the union is 4' - assert det['Iterators profile']['Type'] == 'INTERSECT' + assert ( + det["Iterators profile"]["Child iterators"][0]["Child iterators"] + == "The number of iterators in the union is 3" + ) + assert ( + det["Iterators profile"]["Child iterators"][1]["Child iterators"] + == "The number of iterators in the union is 4" + ) + assert det["Iterators profile"]["Type"] == "INTERSECT" assert len(res.docs) == 3 # check also the search result diff --git a/tests/test_sentinel.py b/tests/test_sentinel.py index 9377d5ba65..0357443a14 100644 --- a/tests/test_sentinel.py +++ b/tests/test_sentinel.py @@ -2,10 +2,14 @@ import pytest -from redis import exceptions -from redis.sentinel import (Sentinel, SentinelConnectionPool, - MasterNotFoundError, SlaveNotFoundError) import redis.sentinel +from redis import exceptions +from redis.sentinel import ( + MasterNotFoundError, + Sentinel, + SentinelConnectionPool, + SlaveNotFoundError, +) @pytest.fixture(scope="module") @@ -33,20 +37,20 @@ def sentinel_slaves(self, master_name): def execute_command(self, *args, **kwargs): # wrapper purely to validate the calls don't explode from redis.client import bool_ok + return bool_ok class SentinelTestCluster: - def __init__(self, servisentinel_ce_name='mymaster', ip='127.0.0.1', - port=6379): + def __init__(self, servisentinel_ce_name="mymaster", ip="127.0.0.1", port=6379): self.clients = {} self.master = { - 'ip': ip, - 'port': port, - 'is_master': True, - 'is_sdown': False, - 'is_odown': False, - 'num-other-sentinels': 0, + "ip": ip, + "port": port, + "is_master": True, + "is_sdown": False, + "is_odown": False, + "num-other-sentinels": 0, } self.service_name = servisentinel_ce_name self.slaves = [] @@ -69,6 +73,7 @@ def client(self, host, port, **kwargs): def cluster(request, master_ip): def teardown(): redis.sentinel.Redis = saved_Redis + cluster = SentinelTestCluster(ip=master_ip) saved_Redis = redis.sentinel.Redis redis.sentinel.Redis = cluster.client @@ -78,126 +83,121 @@ def teardown(): @pytest.fixture() def sentinel(request, cluster): - return Sentinel([('foo', 26379), ('bar', 26379)]) + return Sentinel([("foo", 26379), ("bar", 26379)]) @pytest.mark.onlynoncluster def test_discover_master(sentinel, master_ip): - address = sentinel.discover_master('mymaster') + address = sentinel.discover_master("mymaster") assert address == (master_ip, 6379) @pytest.mark.onlynoncluster def test_discover_master_error(sentinel): with pytest.raises(MasterNotFoundError): - sentinel.discover_master('xxx') + sentinel.discover_master("xxx") @pytest.mark.onlynoncluster def test_discover_master_sentinel_down(cluster, sentinel, master_ip): # Put first sentinel 'foo' down - cluster.nodes_down.add(('foo', 26379)) - address = sentinel.discover_master('mymaster') + cluster.nodes_down.add(("foo", 26379)) + address = sentinel.discover_master("mymaster") assert address == (master_ip, 6379) # 'bar' is now first sentinel - assert sentinel.sentinels[0].id == ('bar', 26379) + assert sentinel.sentinels[0].id == ("bar", 26379) @pytest.mark.onlynoncluster def test_discover_master_sentinel_timeout(cluster, sentinel, master_ip): # Put first sentinel 'foo' down - cluster.nodes_timeout.add(('foo', 26379)) - address = sentinel.discover_master('mymaster') + cluster.nodes_timeout.add(("foo", 26379)) + address = sentinel.discover_master("mymaster") assert address == (master_ip, 6379) # 'bar' is now first sentinel - assert sentinel.sentinels[0].id == ('bar', 26379) + assert sentinel.sentinels[0].id == ("bar", 26379) @pytest.mark.onlynoncluster def test_master_min_other_sentinels(cluster, master_ip): - sentinel = Sentinel([('foo', 26379)], min_other_sentinels=1) + sentinel = Sentinel([("foo", 26379)], min_other_sentinels=1) # min_other_sentinels with pytest.raises(MasterNotFoundError): - sentinel.discover_master('mymaster') - cluster.master['num-other-sentinels'] = 2 - address = sentinel.discover_master('mymaster') + sentinel.discover_master("mymaster") + cluster.master["num-other-sentinels"] = 2 + address = sentinel.discover_master("mymaster") assert address == (master_ip, 6379) @pytest.mark.onlynoncluster def test_master_odown(cluster, sentinel): - cluster.master['is_odown'] = True + cluster.master["is_odown"] = True with pytest.raises(MasterNotFoundError): - sentinel.discover_master('mymaster') + sentinel.discover_master("mymaster") @pytest.mark.onlynoncluster def test_master_sdown(cluster, sentinel): - cluster.master['is_sdown'] = True + cluster.master["is_sdown"] = True with pytest.raises(MasterNotFoundError): - sentinel.discover_master('mymaster') + sentinel.discover_master("mymaster") @pytest.mark.onlynoncluster def test_discover_slaves(cluster, sentinel): - assert sentinel.discover_slaves('mymaster') == [] + assert sentinel.discover_slaves("mymaster") == [] cluster.slaves = [ - {'ip': 'slave0', 'port': 1234, 'is_odown': False, 'is_sdown': False}, - {'ip': 'slave1', 'port': 1234, 'is_odown': False, 'is_sdown': False}, + {"ip": "slave0", "port": 1234, "is_odown": False, "is_sdown": False}, + {"ip": "slave1", "port": 1234, "is_odown": False, "is_sdown": False}, ] - assert sentinel.discover_slaves('mymaster') == [ - ('slave0', 1234), ('slave1', 1234)] + assert sentinel.discover_slaves("mymaster") == [("slave0", 1234), ("slave1", 1234)] # slave0 -> ODOWN - cluster.slaves[0]['is_odown'] = True - assert sentinel.discover_slaves('mymaster') == [ - ('slave1', 1234)] + cluster.slaves[0]["is_odown"] = True + assert sentinel.discover_slaves("mymaster") == [("slave1", 1234)] # slave1 -> SDOWN - cluster.slaves[1]['is_sdown'] = True - assert sentinel.discover_slaves('mymaster') == [] + cluster.slaves[1]["is_sdown"] = True + assert sentinel.discover_slaves("mymaster") == [] - cluster.slaves[0]['is_odown'] = False - cluster.slaves[1]['is_sdown'] = False + cluster.slaves[0]["is_odown"] = False + cluster.slaves[1]["is_sdown"] = False # node0 -> DOWN - cluster.nodes_down.add(('foo', 26379)) - assert sentinel.discover_slaves('mymaster') == [ - ('slave0', 1234), ('slave1', 1234)] + cluster.nodes_down.add(("foo", 26379)) + assert sentinel.discover_slaves("mymaster") == [("slave0", 1234), ("slave1", 1234)] cluster.nodes_down.clear() # node0 -> TIMEOUT - cluster.nodes_timeout.add(('foo', 26379)) - assert sentinel.discover_slaves('mymaster') == [ - ('slave0', 1234), ('slave1', 1234)] + cluster.nodes_timeout.add(("foo", 26379)) + assert sentinel.discover_slaves("mymaster") == [("slave0", 1234), ("slave1", 1234)] @pytest.mark.onlynoncluster def test_master_for(cluster, sentinel, master_ip): - master = sentinel.master_for('mymaster', db=9) + master = sentinel.master_for("mymaster", db=9) assert master.ping() assert master.connection_pool.master_address == (master_ip, 6379) # Use internal connection check - master = sentinel.master_for('mymaster', db=9, check_connection=True) + master = sentinel.master_for("mymaster", db=9, check_connection=True) assert master.ping() @pytest.mark.onlynoncluster def test_slave_for(cluster, sentinel): cluster.slaves = [ - {'ip': '127.0.0.1', 'port': 6379, - 'is_odown': False, 'is_sdown': False}, + {"ip": "127.0.0.1", "port": 6379, "is_odown": False, "is_sdown": False}, ] - slave = sentinel.slave_for('mymaster', db=9) + slave = sentinel.slave_for("mymaster", db=9) assert slave.ping() @pytest.mark.onlynoncluster def test_slave_for_slave_not_found_error(cluster, sentinel): - cluster.master['is_odown'] = True - slave = sentinel.slave_for('mymaster', db=9) + cluster.master["is_odown"] = True + slave = sentinel.slave_for("mymaster", db=9) with pytest.raises(SlaveNotFoundError): slave.ping() @@ -205,13 +205,13 @@ def test_slave_for_slave_not_found_error(cluster, sentinel): @pytest.mark.onlynoncluster def test_slave_round_robin(cluster, sentinel, master_ip): cluster.slaves = [ - {'ip': 'slave0', 'port': 6379, 'is_odown': False, 'is_sdown': False}, - {'ip': 'slave1', 'port': 6379, 'is_odown': False, 'is_sdown': False}, + {"ip": "slave0", "port": 6379, "is_odown": False, "is_sdown": False}, + {"ip": "slave1", "port": 6379, "is_odown": False, "is_sdown": False}, ] - pool = SentinelConnectionPool('mymaster', sentinel) + pool = SentinelConnectionPool("mymaster", sentinel) rotator = pool.rotate_slaves() - assert next(rotator) in (('slave0', 6379), ('slave1', 6379)) - assert next(rotator) in (('slave0', 6379), ('slave1', 6379)) + assert next(rotator) in (("slave0", 6379), ("slave1", 6379)) + assert next(rotator) in (("slave0", 6379), ("slave1", 6379)) # Fallback to master assert next(rotator) == (master_ip, 6379) with pytest.raises(SlaveNotFoundError): @@ -230,5 +230,5 @@ def test_flushconfig(cluster, sentinel): @pytest.mark.onlynoncluster def test_reset(cluster, sentinel): - cluster.master['is_odown'] = True - assert sentinel.sentinel_reset('mymaster') + cluster.master["is_odown"] = True + assert sentinel.sentinel_reset("mymaster") diff --git a/tests/test_timeseries.py b/tests/test_timeseries.py index 07433574f1..8c97ab804d 100644 --- a/tests/test_timeseries.py +++ b/tests/test_timeseries.py @@ -1,6 +1,8 @@ -import pytest import time from time import sleep + +import pytest + from .conftest import skip_ifmodversion_lt @@ -68,8 +70,7 @@ def test_add(client): assert 4 == client.ts().add( 4, 4, 2, retention_msecs=10, labels={"Redis": "Labs", "Time": "Series"} ) - assert round(time.time()) == \ - round(float(client.ts().add(5, "*", 1)) / 1000) + assert round(time.time()) == round(float(client.ts().add(5, "*", 1)) / 1000) info = client.ts().info(4) assert 10 == info.retention_msecs @@ -88,12 +89,7 @@ def test_add_duplicate_policy(client): # Test for duplicate policy BLOCK assert 1 == client.ts().add("time-serie-add-ooo-block", 1, 5.0) with pytest.raises(Exception): - client.ts().add( - "time-serie-add-ooo-block", - 1, - 5.0, - duplicate_policy="block" - ) + client.ts().add("time-serie-add-ooo-block", 1, 5.0, duplicate_policy="block") # Test for duplicate policy LAST assert 1 == client.ts().add("time-serie-add-ooo-last", 1, 5.0) @@ -127,8 +123,7 @@ def test_add_duplicate_policy(client): @pytest.mark.redismod def test_madd(client): client.ts().create("a") - assert [1, 2, 3] == \ - client.ts().madd([("a", 1, 5), ("a", 2, 10), ("a", 3, 15)]) + assert [1, 2, 3] == client.ts().madd([("a", 1, 5), ("a", 2, 10), ("a", 3, 15)]) @pytest.mark.redismod @@ -206,13 +201,7 @@ def test_range(client): assert 200 == len(client.ts().range(1, 0, 500)) # last sample isn't returned assert 20 == len( - client.ts().range( - 1, - 0, - 500, - aggregation_type="avg", - bucket_size_msec=10 - ) + client.ts().range(1, 0, 500, aggregation_type="avg", bucket_size_msec=10) ) assert 10 == len(client.ts().range(1, 0, 500, count=10)) @@ -253,13 +242,7 @@ def test_rev_range(client): assert 200 == len(client.ts().range(1, 0, 500)) # first sample isn't returned assert 20 == len( - client.ts().revrange( - 1, - 0, - 500, - aggregation_type="avg", - bucket_size_msec=10 - ) + client.ts().revrange(1, 0, 500, aggregation_type="avg", bucket_size_msec=10) ) assert 10 == len(client.ts().revrange(1, 0, 500, count=10)) assert 2 == len( @@ -283,10 +266,7 @@ def test_rev_range(client): @pytest.mark.redismod def testMultiRange(client): client.ts().create(1, labels={"Test": "This", "team": "ny"}) - client.ts().create( - 2, - labels={"Test": "This", "Taste": "That", "team": "sf"} - ) + client.ts().create(2, labels={"Test": "This", "Taste": "That", "team": "sf"}) for i in range(100): client.ts().add(1, i, i % 7) client.ts().add(2, i, i % 11) @@ -301,11 +281,7 @@ def testMultiRange(client): for i in range(100): client.ts().add(1, i + 200, i % 7) res = client.ts().mrange( - 0, - 500, - filters=["Test=This"], - aggregation_type="avg", - bucket_size_msec=10 + 0, 500, filters=["Test=This"], aggregation_type="avg", bucket_size_msec=10 ) assert 2 == len(res) assert 20 == len(res[0]["1"][1]) @@ -320,21 +296,13 @@ def testMultiRange(client): @skip_ifmodversion_lt("99.99.99", "timeseries") def test_multi_range_advanced(client): client.ts().create(1, labels={"Test": "This", "team": "ny"}) - client.ts().create( - 2, - labels={"Test": "This", "Taste": "That", "team": "sf"} - ) + client.ts().create(2, labels={"Test": "This", "Taste": "That", "team": "sf"}) for i in range(100): client.ts().add(1, i, i % 7) client.ts().add(2, i, i % 11) # test with selected labels - res = client.ts().mrange( - 0, - 200, - filters=["Test=This"], - select_labels=["team"] - ) + res = client.ts().mrange(0, 200, filters=["Test=This"], select_labels=["team"]) assert {"team": "ny"} == res[0]["1"][0] assert {"team": "sf"} == res[1]["2"][0] @@ -350,28 +318,11 @@ def test_multi_range_advanced(client): assert [(15, 1.0), (16, 2.0)] == res[0]["1"][1] # test groupby - res = client.ts().mrange( - 0, - 3, - filters=["Test=This"], - groupby="Test", - reduce="sum" - ) + res = client.ts().mrange(0, 3, filters=["Test=This"], groupby="Test", reduce="sum") assert [(0, 0.0), (1, 2.0), (2, 4.0), (3, 6.0)] == res[0]["Test=This"][1] - res = client.ts().mrange( - 0, - 3, - filters=["Test=This"], - groupby="Test", - reduce="max" - ) + res = client.ts().mrange(0, 3, filters=["Test=This"], groupby="Test", reduce="max") assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[0]["Test=This"][1] - res = client.ts().mrange( - 0, - 3, - filters=["Test=This"], - groupby="team", - reduce="min") + res = client.ts().mrange(0, 3, filters=["Test=This"], groupby="team", reduce="min") assert 2 == len(res) assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[0]["team=ny"][1] assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[1]["team=sf"][1] @@ -401,10 +352,7 @@ def test_multi_range_advanced(client): @skip_ifmodversion_lt("99.99.99", "timeseries") def test_multi_reverse_range(client): client.ts().create(1, labels={"Test": "This", "team": "ny"}) - client.ts().create( - 2, - labels={"Test": "This", "Taste": "That", "team": "sf"} - ) + client.ts().create(2, labels={"Test": "This", "Taste": "That", "team": "sf"}) for i in range(100): client.ts().add(1, i, i % 7) client.ts().add(2, i, i % 11) @@ -419,31 +367,18 @@ def test_multi_reverse_range(client): for i in range(100): client.ts().add(1, i + 200, i % 7) res = client.ts().mrevrange( - 0, - 500, - filters=["Test=This"], - aggregation_type="avg", - bucket_size_msec=10 + 0, 500, filters=["Test=This"], aggregation_type="avg", bucket_size_msec=10 ) assert 2 == len(res) assert 20 == len(res[0]["1"][1]) assert {} == res[0]["1"][0] # test withlabels - res = client.ts().mrevrange( - 0, - 200, - filters=["Test=This"], - with_labels=True - ) + res = client.ts().mrevrange(0, 200, filters=["Test=This"], with_labels=True) assert {"Test": "This", "team": "ny"} == res[0]["1"][0] # test with selected labels - res = client.ts().mrevrange( - 0, - 200, - filters=["Test=This"], select_labels=["team"] - ) + res = client.ts().mrevrange(0, 200, filters=["Test=This"], select_labels=["team"]) assert {"team": "ny"} == res[0]["1"][0] assert {"team": "sf"} == res[1]["2"][0] @@ -529,11 +464,7 @@ def test_mget(client): @pytest.mark.redismod def test_info(client): - client.ts().create( - 1, - retention_msecs=5, - labels={"currentLabel": "currentData"} - ) + client.ts().create(1, retention_msecs=5, labels={"currentLabel": "currentData"}) info = client.ts().info(1) assert 5 == info.retention_msecs assert info.labels["currentLabel"] == "currentData" @@ -542,11 +473,7 @@ def test_info(client): @pytest.mark.redismod @skip_ifmodversion_lt("1.4.0", "timeseries") def testInfoDuplicatePolicy(client): - client.ts().create( - 1, - retention_msecs=5, - labels={"currentLabel": "currentData"} - ) + client.ts().create(1, retention_msecs=5, labels={"currentLabel": "currentData"}) info = client.ts().info(1) assert info.duplicate_policy is None diff --git a/tox.ini b/tox.ini index f710bbaca8..9d78e2a028 100644 --- a/tox.ini +++ b/tox.ini @@ -90,6 +90,9 @@ healtcheck_cmd = python -c "import socket;print(True) if all([0 == socket.socket volumes = bind:rw:{toxinidir}/docker/cluster/redis.conf:/redis.conf +[isort] +profile = black +multi_line_output = 3 [testenv] deps = @@ -130,7 +133,9 @@ commands = /usr/bin/echo deps_files = dev_requirements.txt docker = commands = - flake8 --max-line-length=88 + flake8 + black --target-version py36 --check --diff . + isort --check-only --diff . vulture redis whitelist.py --min-confidence 80 flynt --fail-on-change --dry-run . skipsdist = true @@ -150,6 +155,7 @@ allowlist_externals = make commands = make html [flake8] +max-line-length = 88 exclude = *.egg-info, *.pyc, @@ -162,3 +168,7 @@ exclude = docker, venv*, whitelist.py +ignore = + W503 + E203 + E126 From 175a05f4de17918b74bde7f554182968b1f6aabb Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Tue, 30 Nov 2021 17:47:25 +0100 Subject: [PATCH 0280/1164] Adding RedisGraph support (#1673) Co-authored-by: Chayim I. Kirshen --- redis/commands/graph/__init__.py | 162 +++++++++ redis/commands/graph/commands.py | 200 +++++++++++ redis/commands/graph/edge.py | 87 +++++ redis/commands/graph/exceptions.py | 3 + redis/commands/graph/node.py | 84 +++++ redis/commands/graph/path.py | 74 +++++ redis/commands/graph/query_result.py | 362 ++++++++++++++++++++ redis/commands/helpers.py | 38 +++ redis/commands/redismodules.py | 10 + setup.py | 1 + tests/test_graph.py | 477 +++++++++++++++++++++++++++ tests/test_graph_utils/__init__.py | 0 tests/test_graph_utils/test_edge.py | 77 +++++ tests/test_graph_utils/test_node.py | 52 +++ tests/test_graph_utils/test_path.py | 91 +++++ tox.ini | 3 +- 16 files changed, 1720 insertions(+), 1 deletion(-) create mode 100644 redis/commands/graph/__init__.py create mode 100644 redis/commands/graph/commands.py create mode 100644 redis/commands/graph/edge.py create mode 100644 redis/commands/graph/exceptions.py create mode 100644 redis/commands/graph/node.py create mode 100644 redis/commands/graph/path.py create mode 100644 redis/commands/graph/query_result.py create mode 100644 tests/test_graph.py create mode 100644 tests/test_graph_utils/__init__.py create mode 100644 tests/test_graph_utils/test_edge.py create mode 100644 tests/test_graph_utils/test_node.py create mode 100644 tests/test_graph_utils/test_path.py diff --git a/redis/commands/graph/__init__.py b/redis/commands/graph/__init__.py new file mode 100644 index 0000000000..7b9972ad9b --- /dev/null +++ b/redis/commands/graph/__init__.py @@ -0,0 +1,162 @@ +from ..helpers import quote_string, random_string, stringify_param_value +from .commands import GraphCommands +from .edge import Edge # noqa +from .node import Node # noqa +from .path import Path # noqa + + +class Graph(GraphCommands): + """ + Graph, collection of nodes and edges. + """ + + def __init__(self, client, name=random_string()): + """ + Create a new graph. + """ + self.NAME = name # Graph key + self.client = client + self.execute_command = client.execute_command + + self.nodes = {} + self.edges = [] + self._labels = [] # List of node labels. + self._properties = [] # List of properties. + self._relationshipTypes = [] # List of relation types. + self.version = 0 # Graph version + + @property + def name(self): + return self.NAME + + def _clear_schema(self): + self._labels = [] + self._properties = [] + self._relationshipTypes = [] + + def _refresh_schema(self): + self._clear_schema() + self._refresh_labels() + self._refresh_relations() + self._refresh_attributes() + + def _refresh_labels(self): + lbls = self.labels() + + # Unpack data. + self._labels = [None] * len(lbls) + for i, l in enumerate(lbls): + self._labels[i] = l[0] + + def _refresh_relations(self): + rels = self.relationshipTypes() + + # Unpack data. + self._relationshipTypes = [None] * len(rels) + for i, r in enumerate(rels): + self._relationshipTypes[i] = r[0] + + def _refresh_attributes(self): + props = self.propertyKeys() + + # Unpack data. + self._properties = [None] * len(props) + for i, p in enumerate(props): + self._properties[i] = p[0] + + def get_label(self, idx): + """ + Returns a label by it's index + + Args: + + idx: + The index of the label + """ + try: + label = self._labels[idx] + except IndexError: + # Refresh labels. + self._refresh_labels() + label = self._labels[idx] + return label + + def get_relation(self, idx): + """ + Returns a relationship type by it's index + + Args: + + idx: + The index of the relation + """ + try: + relationship_type = self._relationshipTypes[idx] + except IndexError: + # Refresh relationship types. + self._refresh_relations() + relationship_type = self._relationshipTypes[idx] + return relationship_type + + def get_property(self, idx): + """ + Returns a property by it's index + + Args: + + idx: + The index of the property + """ + try: + propertie = self._properties[idx] + except IndexError: + # Refresh properties. + self._refresh_attributes() + propertie = self._properties[idx] + return propertie + + def add_node(self, node): + """ + Adds a node to the graph. + """ + if node.alias is None: + node.alias = random_string() + self.nodes[node.alias] = node + + def add_edge(self, edge): + """ + Adds an edge to the graph. + """ + if not (self.nodes[edge.src_node.alias] and self.nodes[edge.dest_node.alias]): + raise AssertionError("Both edge's end must be in the graph") + + self.edges.append(edge) + + def _build_params_header(self, params): + if not isinstance(params, dict): + raise TypeError("'params' must be a dict") + # Header starts with "CYPHER" + params_header = "CYPHER " + for key, value in params.items(): + params_header += str(key) + "=" + stringify_param_value(value) + " " + return params_header + + # Procedures. + def call_procedure(self, procedure, *args, read_only=False, **kwagrs): + args = [quote_string(arg) for arg in args] + q = f"CALL {procedure}({','.join(args)})" + + y = kwagrs.get("y", None) + if y: + q += f" YIELD {','.join(y)}" + + return self.query(q, read_only=read_only) + + def labels(self): + return self.call_procedure("db.labels", read_only=True).result_set + + def relationshipTypes(self): + return self.call_procedure("db.relationshipTypes", read_only=True).result_set + + def propertyKeys(self): + return self.call_procedure("db.propertyKeys", read_only=True).result_set diff --git a/redis/commands/graph/commands.py b/redis/commands/graph/commands.py new file mode 100644 index 0000000000..f0c1d687ed --- /dev/null +++ b/redis/commands/graph/commands.py @@ -0,0 +1,200 @@ +from redis import DataError +from redis.exceptions import ResponseError + +from .exceptions import VersionMismatchException +from .query_result import QueryResult + + +class GraphCommands: + def commit(self): + """ + Create entire graph. + For more information see `CREATE `_. # noqa + """ + if len(self.nodes) == 0 and len(self.edges) == 0: + return None + + query = "CREATE " + for _, node in self.nodes.items(): + query += str(node) + "," + + query += ",".join([str(edge) for edge in self.edges]) + + # Discard leading comma. + if query[-1] == ",": + query = query[:-1] + + return self.query(query) + + def query(self, q, params=None, timeout=None, read_only=False, profile=False): + """ + Executes a query against the graph. + For more information see `GRAPH.QUERY `_. # noqa + + Args: + + ------- + q : + The query. + params : dict + Query parameters. + timeout : int + Maximum runtime for read queries in milliseconds. + read_only : bool + Executes a readonly query if set to True. + profile : bool + Return details on results produced by and time + spent in each operation. + """ + + # maintain original 'q' + query = q + + # handle query parameters + if params is not None: + query = self._build_params_header(params) + query + + # construct query command + # ask for compact result-set format + # specify known graph version + if profile: + cmd = "GRAPH.PROFILE" + else: + cmd = "GRAPH.RO_QUERY" if read_only else "GRAPH.QUERY" + command = [cmd, self.name, query, "--compact"] + + # include timeout is specified + if timeout: + if not isinstance(timeout, int): + raise Exception("Timeout argument must be a positive integer") + command += ["timeout", timeout] + + # issue query + try: + response = self.execute_command(*command) + return QueryResult(self, response, profile) + except ResponseError as e: + if "wrong number of arguments" in str(e): + print( + "Note: RedisGraph Python requires server version 2.2.8 or above" + ) # noqa + if "unknown command" in str(e) and read_only: + # `GRAPH.RO_QUERY` is unavailable in older versions. + return self.query(q, params, timeout, read_only=False) + raise e + except VersionMismatchException as e: + # client view over the graph schema is out of sync + # set client version and refresh local schema + self.version = e.version + self._refresh_schema() + # re-issue query + return self.query(q, params, timeout, read_only) + + def merge(self, pattern): + """ + Merge pattern. + For more information see `MERGE `_. # noqa + """ + query = "MERGE " + query += str(pattern) + + return self.query(query) + + def delete(self): + """ + Deletes graph. + For more information see `DELETE `_. # noqa + """ + self._clear_schema() + return self.execute_command("GRAPH.DELETE", self.name) + + # declared here, to override the built in redis.db.flush() + def flush(self): + """ + Commit the graph and reset the edges and the nodes to zero length. + """ + self.commit() + self.nodes = {} + self.edges = [] + + def explain(self, query, params=None): + """ + Get the execution plan for given query, + Returns an array of operations. + For more information see `GRAPH.EXPLAIN `_. # noqa + + Args: + + ------- + query: + The query that will be executed. + params: dict + Query parameters. + """ + if params is not None: + query = self._build_params_header(params) + query + + plan = self.execute_command("GRAPH.EXPLAIN", self.name, query) + return "\n".join(plan) + + def bulk(self, **kwargs): + """Internal only. Not supported.""" + raise NotImplementedError( + "GRAPH.BULK is internal only. " + "Use https://github.com/redisgraph/redisgraph-bulk-loader." + ) + + def profile(self, query): + """ + Execute a query and produce an execution plan augmented with metrics + for each operation's execution. Return a string representation of a + query execution plan, with details on results produced by and time + spent in each operation. + For more information see `GRAPH.PROFILE `_. # noqa + """ + return self.query(query, profile=True) + + def slowlog(self): + """ + Get a list containing up to 10 of the slowest queries issued + against the given graph ID. + For more information see `GRAPH.SLOWLOG `_. # noqa + + Each item in the list has the following structure: + 1. A unix timestamp at which the log entry was processed. + 2. The issued command. + 3. The issued query. + 4. The amount of time needed for its execution, in milliseconds. + """ + return self.execute_command("GRAPH.SLOWLOG", self.name) + + def config(self, name, value=None, set=False): + """ + Retrieve or update a RedisGraph configuration. + For more information see `GRAPH.CONFIG `_. # noqa + + Args: + + name : str + The name of the configuration + value : + The value we want to ser (can be used only when `set` is on) + set : bool + Turn on to set a configuration. Default behavior is get. + """ + params = ["SET" if set else "GET", name] + if value is not None: + if set: + params.append(value) + else: + raise DataError( + "``value`` can be provided only when ``set`` is True" + ) # noqa + return self.execute_command("GRAPH.CONFIG", *params) + + def list_keys(self): + """ + Lists all graph keys in the keyspace. + For more information see `GRAPH.LIST `_. # noqa + """ + return self.execute_command("GRAPH.LIST") diff --git a/redis/commands/graph/edge.py b/redis/commands/graph/edge.py new file mode 100644 index 0000000000..b334293fb2 --- /dev/null +++ b/redis/commands/graph/edge.py @@ -0,0 +1,87 @@ +from ..helpers import quote_string +from .node import Node + + +class Edge: + """ + An edge connecting two nodes. + """ + + def __init__(self, src_node, relation, dest_node, edge_id=None, properties=None): + """ + Create a new edge. + """ + if src_node is None or dest_node is None: + # NOTE(bors-42): It makes sense to change AssertionError to + # ValueError here + raise AssertionError("Both src_node & dest_node must be provided") + + self.id = edge_id + self.relation = relation or "" + self.properties = properties or {} + self.src_node = src_node + self.dest_node = dest_node + + def toString(self): + res = "" + if self.properties: + props = ",".join( + key + ":" + str(quote_string(val)) + for key, val in sorted(self.properties.items()) + ) + res += "{" + props + "}" + + return res + + def __str__(self): + # Source node. + if isinstance(self.src_node, Node): + res = str(self.src_node) + else: + res = "()" + + # Edge + res += "-[" + if self.relation: + res += ":" + self.relation + if self.properties: + props = ",".join( + key + ":" + str(quote_string(val)) + for key, val in sorted(self.properties.items()) + ) + res += "{" + props + "}" + res += "]->" + + # Dest node. + if isinstance(self.dest_node, Node): + res += str(self.dest_node) + else: + res += "()" + + return res + + def __eq__(self, rhs): + # Quick positive check, if both IDs are set. + if self.id is not None and rhs.id is not None and self.id == rhs.id: + return True + + # Source and destination nodes should match. + if self.src_node != rhs.src_node: + return False + + if self.dest_node != rhs.dest_node: + return False + + # Relation should match. + if self.relation != rhs.relation: + return False + + # Quick check for number of properties. + if len(self.properties) != len(rhs.properties): + return False + + # Compare properties. + if self.properties != rhs.properties: + return False + + return True diff --git a/redis/commands/graph/exceptions.py b/redis/commands/graph/exceptions.py new file mode 100644 index 0000000000..4bbac1008e --- /dev/null +++ b/redis/commands/graph/exceptions.py @@ -0,0 +1,3 @@ +class VersionMismatchException(Exception): + def __init__(self, version): + self.version = version diff --git a/redis/commands/graph/node.py b/redis/commands/graph/node.py new file mode 100644 index 0000000000..47e4eeb8e2 --- /dev/null +++ b/redis/commands/graph/node.py @@ -0,0 +1,84 @@ +from ..helpers import quote_string + + +class Node: + """ + A node within the graph. + """ + + def __init__(self, node_id=None, alias=None, label=None, properties=None): + """ + Create a new node. + """ + self.id = node_id + self.alias = alias + if isinstance(label, list): + label = [inner_label for inner_label in label if inner_label != ""] + + if ( + label is None + or label == "" + or (isinstance(label, list) and len(label) == 0) + ): + self.label = None + self.labels = None + elif isinstance(label, str): + self.label = label + self.labels = [label] + elif isinstance(label, list) and all( + [isinstance(inner_label, str) for inner_label in label] + ): + self.label = label[0] + self.labels = label + else: + raise AssertionError( + "label should be either None, " "string or a list of strings" + ) + + self.properties = properties or {} + + def toString(self): + res = "" + if self.properties: + props = ",".join( + key + ":" + str(quote_string(val)) + for key, val in sorted(self.properties.items()) + ) + res += "{" + props + "}" + + return res + + def __str__(self): + res = "(" + if self.alias: + res += self.alias + if self.labels: + res += ":" + ":".join(self.labels) + if self.properties: + props = ",".join( + key + ":" + str(quote_string(val)) + for key, val in sorted(self.properties.items()) + ) + res += "{" + props + "}" + res += ")" + + return res + + def __eq__(self, rhs): + # Quick positive check, if both IDs are set. + if self.id is not None and rhs.id is not None and self.id != rhs.id: + return False + + # Label should match. + if self.label != rhs.label: + return False + + # Quick check for number of properties. + if len(self.properties) != len(rhs.properties): + return False + + # Compare properties. + if self.properties != rhs.properties: + return False + + return True diff --git a/redis/commands/graph/path.py b/redis/commands/graph/path.py new file mode 100644 index 0000000000..6f2214a3b5 --- /dev/null +++ b/redis/commands/graph/path.py @@ -0,0 +1,74 @@ +from .edge import Edge +from .node import Node + + +class Path: + def __init__(self, nodes, edges): + if not (isinstance(nodes, list) and isinstance(edges, list)): + raise TypeError("nodes and edges must be list") + + self._nodes = nodes + self._edges = edges + self.append_type = Node + + @classmethod + def new_empty_path(cls): + return cls([], []) + + def nodes(self): + return self._nodes + + def edges(self): + return self._edges + + def get_node(self, index): + return self._nodes[index] + + def get_relationship(self, index): + return self._edges[index] + + def first_node(self): + return self._nodes[0] + + def last_node(self): + return self._nodes[-1] + + def edge_count(self): + return len(self._edges) + + def nodes_count(self): + return len(self._nodes) + + def add_node(self, node): + if not isinstance(node, self.append_type): + raise AssertionError("Add Edge before adding Node") + self._nodes.append(node) + self.append_type = Edge + return self + + def add_edge(self, edge): + if not isinstance(edge, self.append_type): + raise AssertionError("Add Node before adding Edge") + self._edges.append(edge) + self.append_type = Node + return self + + def __eq__(self, other): + return self.nodes() == other.nodes() and self.edges() == other.edges() + + def __str__(self): + res = "<" + edge_count = self.edge_count() + for i in range(0, edge_count): + node_id = self.get_node(i).id + res += "(" + str(node_id) + ")" + edge = self.get_relationship(i) + res += ( + "-[" + str(int(edge.id)) + "]->" + if edge.src_node == node_id + else "<-[" + str(int(edge.id)) + "]-" + ) + node_id = self.get_node(edge_count).id + res += "(" + str(node_id) + ")" + res += ">" + return res diff --git a/redis/commands/graph/query_result.py b/redis/commands/graph/query_result.py new file mode 100644 index 0000000000..e9d9f4d3fd --- /dev/null +++ b/redis/commands/graph/query_result.py @@ -0,0 +1,362 @@ +from collections import OrderedDict + +# from prettytable import PrettyTable +from redis import ResponseError + +from .edge import Edge +from .exceptions import VersionMismatchException +from .node import Node +from .path import Path + +LABELS_ADDED = "Labels added" +NODES_CREATED = "Nodes created" +NODES_DELETED = "Nodes deleted" +RELATIONSHIPS_DELETED = "Relationships deleted" +PROPERTIES_SET = "Properties set" +RELATIONSHIPS_CREATED = "Relationships created" +INDICES_CREATED = "Indices created" +INDICES_DELETED = "Indices deleted" +CACHED_EXECUTION = "Cached execution" +INTERNAL_EXECUTION_TIME = "internal execution time" + +STATS = [ + LABELS_ADDED, + NODES_CREATED, + PROPERTIES_SET, + RELATIONSHIPS_CREATED, + NODES_DELETED, + RELATIONSHIPS_DELETED, + INDICES_CREATED, + INDICES_DELETED, + CACHED_EXECUTION, + INTERNAL_EXECUTION_TIME, +] + + +class ResultSetColumnTypes: + COLUMN_UNKNOWN = 0 + COLUMN_SCALAR = 1 + COLUMN_NODE = 2 # Unused as of RedisGraph v2.1.0, retained for backwards compatibility. # noqa + COLUMN_RELATION = 3 # Unused as of RedisGraph v2.1.0, retained for backwards compatibility. # noqa + + +class ResultSetScalarTypes: + VALUE_UNKNOWN = 0 + VALUE_NULL = 1 + VALUE_STRING = 2 + VALUE_INTEGER = 3 + VALUE_BOOLEAN = 4 + VALUE_DOUBLE = 5 + VALUE_ARRAY = 6 + VALUE_EDGE = 7 + VALUE_NODE = 8 + VALUE_PATH = 9 + VALUE_MAP = 10 + VALUE_POINT = 11 + + +class QueryResult: + def __init__(self, graph, response, profile=False): + """ + A class that represents a result of the query operation. + + Args: + + graph: + The graph on which the query was executed. + response: + The response from the server. + profile: + A boolean indicating if the query command was "GRAPH.PROFILE" + """ + self.graph = graph + self.header = [] + self.result_set = [] + + # in case of an error an exception will be raised + self._check_for_errors(response) + + if len(response) == 1: + self.parse_statistics(response[0]) + elif profile: + self.parse_profile(response) + else: + # start by parsing statistics, matches the one we have + self.parse_statistics(response[-1]) # Last element. + self.parse_results(response) + + def _check_for_errors(self, response): + if isinstance(response[0], ResponseError): + error = response[0] + if str(error) == "version mismatch": + version = response[1] + error = VersionMismatchException(version) + raise error + + # If we encountered a run-time error, the last response + # element will be an exception + if isinstance(response[-1], ResponseError): + raise response[-1] + + def parse_results(self, raw_result_set): + self.header = self.parse_header(raw_result_set) + + # Empty header. + if len(self.header) == 0: + return + + self.result_set = self.parse_records(raw_result_set) + + def parse_statistics(self, raw_statistics): + self.statistics = {} + + # decode statistics + for idx, stat in enumerate(raw_statistics): + if isinstance(stat, bytes): + raw_statistics[idx] = stat.decode() + + for s in STATS: + v = self._get_value(s, raw_statistics) + if v is not None: + self.statistics[s] = v + + def parse_header(self, raw_result_set): + # An array of column name/column type pairs. + header = raw_result_set[0] + return header + + def parse_records(self, raw_result_set): + records = [] + result_set = raw_result_set[1] + for row in result_set: + record = [] + for idx, cell in enumerate(row): + if self.header[idx][0] == ResultSetColumnTypes.COLUMN_SCALAR: # noqa + record.append(self.parse_scalar(cell)) + elif self.header[idx][0] == ResultSetColumnTypes.COLUMN_NODE: # noqa + record.append(self.parse_node(cell)) + elif ( + self.header[idx][0] == ResultSetColumnTypes.COLUMN_RELATION + ): # noqa + record.append(self.parse_edge(cell)) + else: + print("Unknown column type.\n") + records.append(record) + + return records + + def parse_entity_properties(self, props): + # [[name, value type, value] X N] + properties = {} + for prop in props: + prop_name = self.graph.get_property(prop[0]) + prop_value = self.parse_scalar(prop[1:]) + properties[prop_name] = prop_value + + return properties + + def parse_string(self, cell): + if isinstance(cell, bytes): + return cell.decode() + elif not isinstance(cell, str): + return str(cell) + else: + return cell + + def parse_node(self, cell): + # Node ID (integer), + # [label string offset (integer)], + # [[name, value type, value] X N] + + node_id = int(cell[0]) + labels = None + if len(cell[1]) > 0: + labels = [] + for inner_label in cell[1]: + labels.append(self.graph.get_label(inner_label)) + properties = self.parse_entity_properties(cell[2]) + return Node(node_id=node_id, label=labels, properties=properties) + + def parse_edge(self, cell): + # Edge ID (integer), + # reltype string offset (integer), + # src node ID offset (integer), + # dest node ID offset (integer), + # [[name, value, value type] X N] + + edge_id = int(cell[0]) + relation = self.graph.get_relation(cell[1]) + src_node_id = int(cell[2]) + dest_node_id = int(cell[3]) + properties = self.parse_entity_properties(cell[4]) + return Edge( + src_node_id, relation, dest_node_id, edge_id=edge_id, properties=properties + ) + + def parse_path(self, cell): + nodes = self.parse_scalar(cell[0]) + edges = self.parse_scalar(cell[1]) + return Path(nodes, edges) + + def parse_map(self, cell): + m = OrderedDict() + n_entries = len(cell) + + # A map is an array of key value pairs. + # 1. key (string) + # 2. array: (value type, value) + for i in range(0, n_entries, 2): + key = self.parse_string(cell[i]) + m[key] = self.parse_scalar(cell[i + 1]) + + return m + + def parse_point(self, cell): + p = {} + # A point is received an array of the form: [latitude, longitude] + # It is returned as a map of the form: {"latitude": latitude, "longitude": longitude} # noqa + p["latitude"] = float(cell[0]) + p["longitude"] = float(cell[1]) + return p + + def parse_scalar(self, cell): + scalar_type = int(cell[0]) + value = cell[1] + scalar = None + + if scalar_type == ResultSetScalarTypes.VALUE_NULL: + scalar = None + + elif scalar_type == ResultSetScalarTypes.VALUE_STRING: + scalar = self.parse_string(value) + + elif scalar_type == ResultSetScalarTypes.VALUE_INTEGER: + scalar = int(value) + + elif scalar_type == ResultSetScalarTypes.VALUE_BOOLEAN: + value = value.decode() if isinstance(value, bytes) else value + if value == "true": + scalar = True + elif value == "false": + scalar = False + else: + print("Unknown boolean type\n") + + elif scalar_type == ResultSetScalarTypes.VALUE_DOUBLE: + scalar = float(value) + + elif scalar_type == ResultSetScalarTypes.VALUE_ARRAY: + # array variable is introduced only for readability + scalar = array = value + for i in range(len(array)): + scalar[i] = self.parse_scalar(array[i]) + + elif scalar_type == ResultSetScalarTypes.VALUE_NODE: + scalar = self.parse_node(value) + + elif scalar_type == ResultSetScalarTypes.VALUE_EDGE: + scalar = self.parse_edge(value) + + elif scalar_type == ResultSetScalarTypes.VALUE_PATH: + scalar = self.parse_path(value) + + elif scalar_type == ResultSetScalarTypes.VALUE_MAP: + scalar = self.parse_map(value) + + elif scalar_type == ResultSetScalarTypes.VALUE_POINT: + scalar = self.parse_point(value) + + elif scalar_type == ResultSetScalarTypes.VALUE_UNKNOWN: + print("Unknown scalar type\n") + + return scalar + + def parse_profile(self, response): + self.result_set = [x[0 : x.index(",")].strip() for x in response] + + # """Prints the data from the query response: + # 1. First row result_set contains the columns names. + # Thus the first row in PrettyTable will contain the + # columns. + # 2. The row after that will contain the data returned, + # or 'No Data returned' if there is none. + # 3. Prints the statistics of the query. + # """ + + # def pretty_print(self): + # if not self.is_empty(): + # header = [col[1] for col in self.header] + # tbl = PrettyTable(header) + + # for row in self.result_set: + # record = [] + # for idx, cell in enumerate(row): + # if type(cell) is Node: + # record.append(cell.toString()) + # elif type(cell) is Edge: + # record.append(cell.toString()) + # else: + # record.append(cell) + # tbl.add_row(record) + + # if len(self.result_set) == 0: + # tbl.add_row(['No data returned.']) + + # print(str(tbl) + '\n') + + # for stat in self.statistics: + # print("%s %s" % (stat, self.statistics[stat])) + + def is_empty(self): + return len(self.result_set) == 0 + + @staticmethod + def _get_value(prop, statistics): + for stat in statistics: + if prop in stat: + return float(stat.split(": ")[1].split(" ")[0]) + + return None + + def _get_stat(self, stat): + return self.statistics[stat] if stat in self.statistics else 0 + + @property + def labels_added(self): + return self._get_stat(LABELS_ADDED) + + @property + def nodes_created(self): + return self._get_stat(NODES_CREATED) + + @property + def nodes_deleted(self): + return self._get_stat(NODES_DELETED) + + @property + def properties_set(self): + return self._get_stat(PROPERTIES_SET) + + @property + def relationships_created(self): + return self._get_stat(RELATIONSHIPS_CREATED) + + @property + def relationships_deleted(self): + return self._get_stat(RELATIONSHIPS_DELETED) + + @property + def indices_created(self): + return self._get_stat(INDICES_CREATED) + + @property + def indices_deleted(self): + return self._get_stat(INDICES_DELETED) + + @property + def cached_execution(self): + return self._get_stat(CACHED_EXECUTION) == 1 + + @property + def run_time_ms(self): + return self._get_stat(INTERNAL_EXECUTION_TIME) diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index 80dfd76a15..afb4f9fae8 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -1,3 +1,4 @@ +import copy import random import string @@ -114,3 +115,40 @@ def quote_string(v): v = v.replace('"', '\\"') return f'"{v}"' + + +def decodeDictKeys(obj): + """Decode the keys of the given dictionary with utf-8.""" + newobj = copy.copy(obj) + for k in obj.keys(): + if isinstance(k, bytes): + newobj[k.decode("utf-8")] = newobj[k] + newobj.pop(k) + return newobj + + +def stringify_param_value(value): + """ + Turn a parameter value into a string suitable for the params header of + a Cypher command. + You may pass any value that would be accepted by `json.dumps()`. + + Ways in which output differs from that of `str()`: + * Strings are quoted. + * None --> "null". + * In dictionaries, keys are _not_ quoted. + + :param value: The parameter value to be turned into a string. + :return: string + """ + + if isinstance(value, str): + return quote_string(value) + elif value is None: + return "null" + elif isinstance(value, (list, tuple)): + return f'[{",".join(map(stringify_param_value, value))}]' + elif isinstance(value, dict): + return f'{{{",".join(f"{k}:{stringify_param_value(v)}" for k, v in value.items())}}}' # noqa + else: + return str(value) diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py index 2420d7b6fb..e5ace63113 100644 --- a/redis/commands/redismodules.py +++ b/redis/commands/redismodules.py @@ -31,3 +31,13 @@ def ts(self): s = TimeSeries(client=self) return s + + def graph(self, index_name="idx"): + """Access the timeseries namespace, providing support for + redis timeseries data. + """ + + from .graph import Graph + + g = Graph(client=self, name=index_name) + return g diff --git a/setup.py b/setup.py index ee91298289..d8308010c9 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ "redis.commands.json", "redis.commands.search", "redis.commands.timeseries", + "redis.commands.graph", ] ), url="https://github.com/redis/redis-py", diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000000..c6dc9a4371 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,477 @@ +import pytest + +from redis.commands.graph import Edge, Node, Path +from redis.exceptions import ResponseError + + +@pytest.fixture +def client(modclient): + modclient.flushdb() + return modclient + + +@pytest.mark.redismod +def test_bulk(client): + with pytest.raises(NotImplementedError): + client.graph().bulk() + client.graph().bulk(foo="bar!") + + +@pytest.mark.redismod +def test_graph_creation(client): + graph = client.graph() + + john = Node( + label="person", + properties={ + "name": "John Doe", + "age": 33, + "gender": "male", + "status": "single", + }, + ) + graph.add_node(john) + japan = Node(label="country", properties={"name": "Japan"}) + + graph.add_node(japan) + edge = Edge(john, "visited", japan, properties={"purpose": "pleasure"}) + graph.add_edge(edge) + + graph.commit() + + query = ( + 'MATCH (p:person)-[v:visited {purpose:"pleasure"}]->(c:country) ' + "RETURN p, v, c" + ) + + result = graph.query(query) + + person = result.result_set[0][0] + visit = result.result_set[0][1] + country = result.result_set[0][2] + + assert person == john + assert visit.properties == edge.properties + assert country == japan + + query = """RETURN [1, 2.3, "4", true, false, null]""" + result = graph.query(query) + assert [1, 2.3, "4", True, False, None] == result.result_set[0][0] + + # All done, remove graph. + graph.delete() + + +@pytest.mark.redismod +def test_array_functions(client): + query = """CREATE (p:person{name:'a',age:32, array:[0,1,2]})""" + client.graph().query(query) + + query = """WITH [0,1,2] as x return x""" + result = client.graph().query(query) + assert [0, 1, 2] == result.result_set[0][0] + + query = """MATCH(n) return collect(n)""" + result = client.graph().query(query) + + a = Node( + node_id=0, + label="person", + properties={"name": "a", "age": 32, "array": [0, 1, 2]}, + ) + + assert [a] == result.result_set[0][0] + + +@pytest.mark.redismod +def test_path(client): + node0 = Node(node_id=0, label="L1") + node1 = Node(node_id=1, label="L1") + edge01 = Edge(node0, "R1", node1, edge_id=0, properties={"value": 1}) + + graph = client.graph() + graph.add_node(node0) + graph.add_node(node1) + graph.add_edge(edge01) + graph.flush() + + path01 = Path.new_empty_path().add_node(node0).add_edge(edge01).add_node(node1) + expected_results = [[path01]] + + query = "MATCH p=(:L1)-[:R1]->(:L1) RETURN p ORDER BY p" + result = graph.query(query) + assert expected_results == result.result_set + + +@pytest.mark.redismod +def test_param(client): + params = [1, 2.3, "str", True, False, None, [0, 1, 2]] + query = "RETURN $param" + for param in params: + result = client.graph().query(query, {"param": param}) + expected_results = [[param]] + assert expected_results == result.result_set + + +@pytest.mark.redismod +def test_map(client): + query = "RETURN {a:1, b:'str', c:NULL, d:[1,2,3], e:True, f:{x:1, y:2}}" + + actual = client.graph().query(query).result_set[0][0] + expected = { + "a": 1, + "b": "str", + "c": None, + "d": [1, 2, 3], + "e": True, + "f": {"x": 1, "y": 2}, + } + + assert actual == expected + + +@pytest.mark.redismod +def test_point(client): + query = "RETURN point({latitude: 32.070794860, longitude: 34.820751118})" + expected_lat = 32.070794860 + expected_lon = 34.820751118 + actual = client.graph().query(query).result_set[0][0] + assert abs(actual["latitude"] - expected_lat) < 0.001 + assert abs(actual["longitude"] - expected_lon) < 0.001 + + query = "RETURN point({latitude: 32, longitude: 34.0})" + expected_lat = 32 + expected_lon = 34 + actual = client.graph().query(query).result_set[0][0] + assert abs(actual["latitude"] - expected_lat) < 0.001 + assert abs(actual["longitude"] - expected_lon) < 0.001 + + +@pytest.mark.redismod +def test_index_response(client): + result_set = client.graph().query("CREATE INDEX ON :person(age)") + assert 1 == result_set.indices_created + + result_set = client.graph().query("CREATE INDEX ON :person(age)") + assert 0 == result_set.indices_created + + result_set = client.graph().query("DROP INDEX ON :person(age)") + assert 1 == result_set.indices_deleted + + with pytest.raises(ResponseError): + client.graph().query("DROP INDEX ON :person(age)") + + +@pytest.mark.redismod +def test_stringify_query_result(client): + graph = client.graph() + + john = Node( + alias="a", + label="person", + properties={ + "name": "John Doe", + "age": 33, + "gender": "male", + "status": "single", + }, + ) + graph.add_node(john) + + japan = Node(alias="b", label="country", properties={"name": "Japan"}) + graph.add_node(japan) + + edge = Edge(john, "visited", japan, properties={"purpose": "pleasure"}) + graph.add_edge(edge) + + assert ( + str(john) + == """(a:person{age:33,gender:"male",name:"John Doe",status:"single"})""" # noqa + ) + assert ( + str(edge) + == """(a:person{age:33,gender:"male",name:"John Doe",status:"single"})""" # noqa + + """-[:visited{purpose:"pleasure"}]->""" + + """(b:country{name:"Japan"})""" + ) + assert str(japan) == """(b:country{name:"Japan"})""" + + graph.commit() + + query = """MATCH (p:person)-[v:visited {purpose:"pleasure"}]->(c:country) + RETURN p, v, c""" + + result = client.graph().query(query) + person = result.result_set[0][0] + visit = result.result_set[0][1] + country = result.result_set[0][2] + + assert ( + str(person) + == """(:person{age:33,gender:"male",name:"John Doe",status:"single"})""" # noqa + ) + assert str(visit) == """()-[:visited{purpose:"pleasure"}]->()""" + assert str(country) == """(:country{name:"Japan"})""" + + graph.delete() + + +@pytest.mark.redismod +def test_optional_match(client): + # Build a graph of form (a)-[R]->(b) + node0 = Node(node_id=0, label="L1", properties={"value": "a"}) + node1 = Node(node_id=1, label="L1", properties={"value": "b"}) + + edge01 = Edge(node0, "R", node1, edge_id=0) + + graph = client.graph() + graph.add_node(node0) + graph.add_node(node1) + graph.add_edge(edge01) + graph.flush() + + # Issue a query that collects all outgoing edges from both nodes + # (the second has none) + query = """MATCH (a) OPTIONAL MATCH (a)-[e]->(b) RETURN a, e, b ORDER BY a.value""" # noqa + expected_results = [[node0, edge01, node1], [node1, None, None]] + + result = client.graph().query(query) + assert expected_results == result.result_set + + graph.delete() + + +@pytest.mark.redismod +def test_cached_execution(client): + client.graph().query("CREATE ()") + + uncached_result = client.graph().query("MATCH (n) RETURN n, $param", {"param": [0]}) + assert uncached_result.cached_execution is False + + # loop to make sure the query is cached on each thread on server + for x in range(0, 64): + cached_result = client.graph().query( + "MATCH (n) RETURN n, $param", {"param": [0]} + ) + assert uncached_result.result_set == cached_result.result_set + + # should be cached on all threads by now + assert cached_result.cached_execution + + +@pytest.mark.redismod +def test_explain(client): + create_query = """CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}), + (:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}), + (:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'})""" + client.graph().query(create_query) + + result = client.graph().explain( + "MATCH (r:Rider)-[:rides]->(t:Team) WHERE t.name = $name RETURN r.name, t.name, $params", # noqa + {"name": "Yehuda"}, + ) + expected = "Results\n Project\n Conditional Traverse | (t:Team)->(r:Rider)\n Filter\n Node By Label Scan | (t:Team)" # noqa + assert result == expected + + +@pytest.mark.redismod +def test_slowlog(client): + create_query = """CREATE (:Rider {name:'Valentino Rossi'})-[:rides]->(:Team {name:'Yamaha'}), + (:Rider {name:'Dani Pedrosa'})-[:rides]->(:Team {name:'Honda'}), + (:Rider {name:'Andrea Dovizioso'})-[:rides]->(:Team {name:'Ducati'})""" + client.graph().query(create_query) + + results = client.graph().slowlog() + assert results[0][1] == "GRAPH.QUERY" + assert results[0][2] == create_query + + +@pytest.mark.redismod +def test_query_timeout(client): + # Build a sample graph with 1000 nodes. + client.graph().query("UNWIND range(0,1000) as val CREATE ({v: val})") + # Issue a long-running query with a 1-millisecond timeout. + with pytest.raises(ResponseError): + client.graph().query("MATCH (a), (b), (c), (d) RETURN *", timeout=1) + assert False is False + + with pytest.raises(Exception): + client.graph().query("RETURN 1", timeout="str") + assert False is False + + +@pytest.mark.redismod +def test_read_only_query(client): + with pytest.raises(Exception): + # Issue a write query, specifying read-only true, + # this call should fail. + client.graph().query("CREATE (p:person {name:'a'})", read_only=True) + assert False is False + + +@pytest.mark.redismod +def test_profile(client): + q = """UNWIND range(1, 3) AS x CREATE (p:Person {v:x})""" + profile = client.graph().profile(q).result_set + assert "Create | Records produced: 3" in profile + assert "Unwind | Records produced: 3" in profile + + q = "MATCH (p:Person) WHERE p.v > 1 RETURN p" + profile = client.graph().profile(q).result_set + assert "Results | Records produced: 2" in profile + assert "Project | Records produced: 2" in profile + assert "Filter | Records produced: 2" in profile + assert "Node By Label Scan | (p:Person) | Records produced: 3" in profile + + +@pytest.mark.redismod +def test_config(client): + config_name = "RESULTSET_SIZE" + config_value = 3 + + # Set configuration + response = client.graph().config(config_name, config_value, set=True) + assert response == "OK" + + # Make sure config been updated. + response = client.graph().config(config_name, set=False) + expected_response = [config_name, config_value] + assert response == expected_response + + config_name = "QUERY_MEM_CAPACITY" + config_value = 1 << 20 # 1MB + + # Set configuration + response = client.graph().config(config_name, config_value, set=True) + assert response == "OK" + + # Make sure config been updated. + response = client.graph().config(config_name, set=False) + expected_response = [config_name, config_value] + assert response == expected_response + + # reset to default + client.graph().config("QUERY_MEM_CAPACITY", 0, set=True) + client.graph().config("RESULTSET_SIZE", -100, set=True) + + +@pytest.mark.redismod +def test_list_keys(client): + result = client.graph().list_keys() + assert result == [] + + client.execute_command("GRAPH.EXPLAIN", "G", "RETURN 1") + result = client.graph().list_keys() + assert result == ["G"] + + client.execute_command("GRAPH.EXPLAIN", "X", "RETURN 1") + result = client.graph().list_keys() + assert result == ["G", "X"] + + client.delete("G") + client.rename("X", "Z") + result = client.graph().list_keys() + assert result == ["Z"] + + client.delete("Z") + result = client.graph().list_keys() + assert result == [] + + +@pytest.mark.redismod +def test_multi_label(client): + redis_graph = client.graph("g") + + node = Node(label=["l", "ll"]) + redis_graph.add_node(node) + redis_graph.commit() + + query = "MATCH (n) RETURN n" + result = redis_graph.query(query) + result_node = result.result_set[0][0] + assert result_node == node + + try: + Node(label=1) + assert False + except AssertionError: + assert True + + try: + Node(label=["l", 1]) + assert False + except AssertionError: + assert True + + +@pytest.mark.redismod +def test_cache_sync(client): + pass + return + # This test verifies that client internal graph schema cache stays + # in sync with the graph schema + # + # Client B will try to get Client A out of sync by: + # 1. deleting the graph + # 2. reconstructing the graph in a different order, this will casuse + # a differance in the current mapping between string IDs and the + # mapping Client A is aware of + # + # Client A should pick up on the changes by comparing graph versions + # and resyncing its cache. + + A = client.graph("cache-sync") + B = client.graph("cache-sync") + + # Build order: + # 1. introduce label 'L' and 'K' + # 2. introduce attribute 'x' and 'q' + # 3. introduce relationship-type 'R' and 'S' + + A.query("CREATE (:L)") + B.query("CREATE (:K)") + A.query("MATCH (n) SET n.x = 1") + B.query("MATCH (n) SET n.q = 1") + A.query("MATCH (n) CREATE (n)-[:R]->()") + B.query("MATCH (n) CREATE (n)-[:S]->()") + + # Cause client A to populate its cache + A.query("MATCH (n)-[e]->() RETURN n, e") + + assert len(A._labels) == 2 + assert len(A._properties) == 2 + assert len(A._relationshipTypes) == 2 + assert A._labels[0] == "L" + assert A._labels[1] == "K" + assert A._properties[0] == "x" + assert A._properties[1] == "q" + assert A._relationshipTypes[0] == "R" + assert A._relationshipTypes[1] == "S" + + # Have client B reconstruct the graph in a different order. + B.delete() + + # Build order: + # 1. introduce relationship-type 'R' + # 2. introduce label 'L' + # 3. introduce attribute 'x' + B.query("CREATE ()-[:S]->()") + B.query("CREATE ()-[:R]->()") + B.query("CREATE (:K)") + B.query("CREATE (:L)") + B.query("MATCH (n) SET n.q = 1") + B.query("MATCH (n) SET n.x = 1") + + # A's internal cached mapping is now out of sync + # issue a query and make sure A's cache is synced. + A.query("MATCH (n)-[e]->() RETURN n, e") + + assert len(A._labels) == 2 + assert len(A._properties) == 2 + assert len(A._relationshipTypes) == 2 + assert A._labels[0] == "K" + assert A._labels[1] == "L" + assert A._properties[0] == "q" + assert A._properties[1] == "x" + assert A._relationshipTypes[0] == "S" + assert A._relationshipTypes[1] == "R" diff --git a/tests/test_graph_utils/__init__.py b/tests/test_graph_utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_graph_utils/test_edge.py b/tests/test_graph_utils/test_edge.py new file mode 100644 index 0000000000..42358de19a --- /dev/null +++ b/tests/test_graph_utils/test_edge.py @@ -0,0 +1,77 @@ +import pytest + +from redis.commands.graph import edge, node + + +@pytest.mark.redismod +def test_init(): + + with pytest.raises(AssertionError): + edge.Edge(None, None, None) + edge.Edge(node.Node(), None, None) + edge.Edge(None, None, node.Node()) + + assert isinstance( + edge.Edge(node.Node(node_id=1), None, node.Node(node_id=2)), edge.Edge + ) + + +@pytest.mark.redismod +def test_toString(): + props_result = edge.Edge( + node.Node(), None, node.Node(), properties={"a": "a", "b": 10} + ).toString() + assert props_result == '{a:"a",b:10}' + + no_props_result = edge.Edge( + node.Node(), None, node.Node(), properties={} + ).toString() + assert no_props_result == "" + + +@pytest.mark.redismod +def test_stringify(): + john = node.Node( + alias="a", + label="person", + properties={"name": "John Doe", "age": 33, "someArray": [1, 2, 3]}, + ) + japan = node.Node(alias="b", label="country", properties={"name": "Japan"}) + edge_with_relation = edge.Edge( + john, "visited", japan, properties={"purpose": "pleasure"} + ) + assert ( + '(a:person{age:33,name:"John Doe",someArray:[1, 2, 3]})' + '-[:visited{purpose:"pleasure"}]->' + '(b:country{name:"Japan"})' == str(edge_with_relation) + ) + + edge_no_relation_no_props = edge.Edge(japan, "", john) + assert ( + '(b:country{name:"Japan"})' + "-[]->" + '(a:person{age:33,name:"John Doe",someArray:[1, 2, 3]})' + == str(edge_no_relation_no_props) + ) + + edge_only_props = edge.Edge(john, "", japan, properties={"a": "b", "c": 3}) + assert ( + '(a:person{age:33,name:"John Doe",someArray:[1, 2, 3]})' + '-[{a:"b",c:3}]->' + '(b:country{name:"Japan"})' == str(edge_only_props) + ) + + +@pytest.mark.redismod +def test_comparision(): + node1 = node.Node(node_id=1) + node2 = node.Node(node_id=2) + node3 = node.Node(node_id=3) + + edge1 = edge.Edge(node1, None, node2) + assert edge1 == edge.Edge(node1, None, node2) + assert edge1 != edge.Edge(node1, "bla", node2) + assert edge1 != edge.Edge(node1, None, node3) + assert edge1 != edge.Edge(node3, None, node2) + assert edge1 != edge.Edge(node2, None, node1) + assert edge1 != edge.Edge(node1, None, node2, properties={"a": 10}) diff --git a/tests/test_graph_utils/test_node.py b/tests/test_graph_utils/test_node.py new file mode 100644 index 0000000000..faf8ab6458 --- /dev/null +++ b/tests/test_graph_utils/test_node.py @@ -0,0 +1,52 @@ +import pytest + +from redis.commands.graph import node + + +@pytest.fixture +def fixture(): + no_args = node.Node() + no_props = node.Node(node_id=1, alias="alias", label="l") + props_only = node.Node(properties={"a": "a", "b": 10}) + no_label = node.Node(node_id=1, alias="alias", properties={"a": "a"}) + multi_label = node.Node(node_id=1, alias="alias", label=["l", "ll"]) + return no_args, no_props, props_only, no_label, multi_label + + +@pytest.mark.redismod +def test_toString(fixture): + no_args, no_props, props_only, no_label, multi_label = fixture + assert no_args.toString() == "" + assert no_props.toString() == "" + assert props_only.toString() == '{a:"a",b:10}' + assert no_label.toString() == '{a:"a"}' + assert multi_label.toString() == "" + + +@pytest.mark.redismod +def test_stringify(fixture): + no_args, no_props, props_only, no_label, multi_label = fixture + assert str(no_args) == "()" + assert str(no_props) == "(alias:l)" + assert str(props_only) == '({a:"a",b:10})' + assert str(no_label) == '(alias{a:"a"})' + assert str(multi_label) == "(alias:l:ll)" + + +@pytest.mark.redismod +def test_comparision(fixture): + no_args, no_props, props_only, no_label, multi_label = fixture + + assert node.Node() == node.Node() + assert node.Node(node_id=1) == node.Node(node_id=1) + assert node.Node(node_id=1) != node.Node(node_id=2) + assert node.Node(node_id=1, alias="a") == node.Node(node_id=1, alias="b") + assert node.Node(node_id=1, alias="a") == node.Node(node_id=1, alias="a") + assert node.Node(node_id=1, label="a") == node.Node(node_id=1, label="a") + assert node.Node(node_id=1, label="a") != node.Node(node_id=1, label="b") + assert node.Node(node_id=1, alias="a", label="l") == node.Node( + node_id=1, alias="a", label="l" + ) + assert node.Node(alias="a", label="l") != node.Node(alias="a", label="l1") + assert node.Node(properties={"a": 10}) == node.Node(properties={"a": 10}) + assert node.Node() != node.Node(properties={"a": 10}) diff --git a/tests/test_graph_utils/test_path.py b/tests/test_graph_utils/test_path.py new file mode 100644 index 0000000000..d581269307 --- /dev/null +++ b/tests/test_graph_utils/test_path.py @@ -0,0 +1,91 @@ +import pytest + +from redis.commands.graph import edge, node, path + + +@pytest.mark.redismod +def test_init(): + with pytest.raises(TypeError): + path.Path(None, None) + path.Path([], None) + path.Path(None, []) + + assert isinstance(path.Path([], []), path.Path) + + +@pytest.mark.redismod +def test_new_empty_path(): + new_empty_path = path.Path.new_empty_path() + assert isinstance(new_empty_path, path.Path) + assert new_empty_path._nodes == [] + assert new_empty_path._edges == [] + + +@pytest.mark.redismod +def test_wrong_flows(): + node_1 = node.Node(node_id=1) + node_2 = node.Node(node_id=2) + node_3 = node.Node(node_id=3) + + edge_1 = edge.Edge(node_1, None, node_2) + edge_2 = edge.Edge(node_1, None, node_3) + + p = path.Path.new_empty_path() + with pytest.raises(AssertionError): + p.add_edge(edge_1) + + p.add_node(node_1) + with pytest.raises(AssertionError): + p.add_node(node_2) + + p.add_edge(edge_1) + with pytest.raises(AssertionError): + p.add_edge(edge_2) + + +@pytest.mark.redismod +def test_nodes_and_edges(): + node_1 = node.Node(node_id=1) + node_2 = node.Node(node_id=2) + edge_1 = edge.Edge(node_1, None, node_2) + + p = path.Path.new_empty_path() + assert p.nodes() == [] + p.add_node(node_1) + assert [] == p.edges() + assert 0 == p.edge_count() + assert [node_1] == p.nodes() + assert node_1 == p.get_node(0) + assert node_1 == p.first_node() + assert node_1 == p.last_node() + assert 1 == p.nodes_count() + p.add_edge(edge_1) + assert [edge_1] == p.edges() + assert 1 == p.edge_count() + assert edge_1 == p.get_relationship(0) + p.add_node(node_2) + assert [node_1, node_2] == p.nodes() + assert node_1 == p.first_node() + assert node_2 == p.last_node() + assert 2 == p.nodes_count() + + +@pytest.mark.redismod +def test_compare(): + node_1 = node.Node(node_id=1) + node_2 = node.Node(node_id=2) + edge_1 = edge.Edge(node_1, None, node_2) + + assert path.Path.new_empty_path() == path.Path.new_empty_path() + assert path.Path(nodes=[node_1, node_2], edges=[edge_1]) == path.Path( + nodes=[node_1, node_2], edges=[edge_1] + ) + assert path.Path(nodes=[node_1], edges=[]) != path.Path(nodes=[], edges=[]) + assert path.Path(nodes=[node_1], edges=[]) != path.Path(nodes=[], edges=[]) + assert path.Path(nodes=[node_1], edges=[]) != path.Path(nodes=[node_2], edges=[]) + assert path.Path(nodes=[node_1], edges=[edge_1]) != path.Path( + nodes=[node_1], edges=[] + ) + assert path.Path(nodes=[node_1], edges=[edge_1]) != path.Path( + nodes=[node_2], edges=[edge_1] + ) diff --git a/tox.ini b/tox.ini index 9d78e2a028..0ccc9bb537 100644 --- a/tox.ini +++ b/tox.ini @@ -2,13 +2,14 @@ addopts = -s markers = redismod: run only the redis module tests + pipeline: pipeline tests onlycluster: marks tests to be run only with cluster mode redis onlynoncluster: marks tests to be run only with standalone redis [tox] minversion = 3.2.0 requires = tox-docker -envlist = {standalone,cluster}-{plain,hiredis}-{py35,py36,py37,py38,py39,pypy3},linters,docs +envlist = {standalone,cluster}-{plain,hiredis}-{py36,py37,py38,py39,pypy3},linters,docs [docker:master] name = master From e16e26ea597e6e0c576d7462d3a2285a8647617d Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Wed, 1 Dec 2021 17:32:38 +0100 Subject: [PATCH 0281/1164] Remove unused aggregation subclasses (#1754) --- redis/commands/search/aggregation.py | 104 +++++++-------------------- 1 file changed, 27 insertions(+), 77 deletions(-) diff --git a/redis/commands/search/aggregation.py b/redis/commands/search/aggregation.py index b1ac6b04dc..3db5542ae1 100644 --- a/redis/commands/search/aggregation.py +++ b/redis/commands/search/aggregation.py @@ -82,75 +82,6 @@ class Desc(SortDirection): DIRSTRING = "DESC" -class Group: - """ - This object automatically created in the `AggregateRequest.group_by()` - """ - - def __init__(self, fields, reducers): - if not reducers: - raise ValueError("Need at least one reducer") - - fields = [fields] if isinstance(fields, str) else fields - reducers = [reducers] if isinstance(reducers, Reducer) else reducers - - self.fields = fields - self.reducers = reducers - self.limit = Limit() - - def build_args(self): - ret = ["GROUPBY", str(len(self.fields))] - ret.extend(self.fields) - for reducer in self.reducers: - ret += ["REDUCE", reducer.NAME, str(len(reducer.args))] - ret.extend(reducer.args) - if reducer._alias is not None: - ret += ["AS", reducer._alias] - return ret - - -class Projection: - """ - This object automatically created in the `AggregateRequest.apply()` - """ - - def __init__(self, projector, alias=None): - self.alias = alias - self.projector = projector - - def build_args(self): - ret = ["APPLY", self.projector] - if self.alias is not None: - ret += ["AS", self.alias] - - return ret - - -class SortBy: - """ - This object automatically created in the `AggregateRequest.sort_by()` - """ - - def __init__(self, fields, max=0): - self.fields = fields - self.max = max - - def build_args(self): - fields_args = [] - for f in self.fields: - if isinstance(f, SortDirection): - fields_args += [f.field, f.DIRSTRING] - else: - fields_args += [f] - - ret = ["SORTBY", str(len(fields_args))] - ret.extend(fields_args) - if self.max > 0: - ret += ["MAX", str(self.max)] - - return ret - - class AggregateRequest: """ Aggregation request which can be passed to `Client.aggregate`. @@ -202,9 +133,17 @@ def group_by(self, fields, *reducers): - **reducers**: One or more reducers. Reducers may be found in the `aggregation` module. """ - group = Group(fields, reducers) - self._aggregateplan.extend(group.build_args()) + fields = [fields] if isinstance(fields, str) else fields + reducers = [reducers] if isinstance(reducers, Reducer) else reducers + ret = ["GROUPBY", str(len(fields)), *fields] + for reducer in reducers: + ret += ["REDUCE", reducer.NAME, str(len(reducer.args))] + ret.extend(reducer.args) + if reducer._alias is not None: + ret += ["AS", reducer._alias] + + self._aggregateplan.extend(ret) return self def apply(self, **kwexpr): @@ -218,8 +157,10 @@ def apply(self, **kwexpr): expression itself, for example `apply(square_root="sqrt(@foo)")` """ for alias, expr in kwexpr.items(): - projection = Projection(expr, alias) - self._aggregateplan.extend(projection.build_args()) + ret = ["APPLY", expr] + if alias is not None: + ret += ["AS", alias] + self._aggregateplan.extend(ret) return self @@ -265,8 +206,7 @@ def limit(self, offset, num): `sort_by()` instead. """ - limit = Limit(offset, num) - self._limit = limit + self._limit = Limit(offset, num) return self def sort_by(self, *fields, **kwargs): @@ -300,10 +240,20 @@ def sort_by(self, *fields, **kwargs): if isinstance(fields, (str, SortDirection)): fields = [fields] + fields_args = [] + for f in fields: + if isinstance(f, SortDirection): + fields_args += [f.field, f.DIRSTRING] + else: + fields_args += [f] + + ret = ["SORTBY", str(len(fields_args))] + ret.extend(fields_args) max = kwargs.get("max", 0) - sortby = SortBy(fields, max) + if max > 0: + ret += ["MAX", str(max)] - self._aggregateplan.extend(sortby.build_args()) + self._aggregateplan.extend(ret) return self def filter(self, expressions): From d4252277a9dafed5af34b3f40ed7a57fc952d273 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Wed, 1 Dec 2021 18:33:44 +0200 Subject: [PATCH 0282/1164] Migrated targeted nodes to kwargs in Cluster Mode (#1762) --- README.md | 4 +- redis/client.py | 12 + redis/cluster.py | 39 ++- redis/commands/__init__.py | 4 +- redis/commands/cluster.py | 598 +++---------------------------------- redis/commands/core.py | 354 ++++++++++++---------- tests/test_cluster.py | 63 +++- tests/test_commands.py | 15 +- 8 files changed, 342 insertions(+), 747 deletions(-) diff --git a/README.md b/README.md index d068c68f14..f9d6309231 100644 --- a/README.md +++ b/README.md @@ -1046,7 +1046,7 @@ and attempt to retry executing the command. >>> rc.cluster_meet('127.0.0.1', 6379, target_nodes=Redis.ALL_NODES) >>> # ping all replicas >>> rc.ping(target_nodes=Redis.REPLICAS) - >>> # ping a specific node + >>> # ping a random node >>> rc.ping(target_nodes=Redis.RANDOM) >>> # get the keys from all cluster nodes >>> rc.keys(target_nodes=Redis.ALL_NODES) @@ -1158,7 +1158,7 @@ readwrite() method. >>> from cluster import RedisCluster as Redis # Use 'debug' log level to print the node that the command is executed on >>> rc_readonly = Redis(startup_nodes=startup_nodes, - read_from_replicas=True, debug=True) + read_from_replicas=True) >>> rc_readonly.set('{foo}1', 'bar1') >>> for i in range(0, 4): # Assigns read command to the slot's hosts in a Round-Robin manner diff --git a/redis/client.py b/redis/client.py index 14e588a1d7..c02bc3a4d5 100755 --- a/redis/client.py +++ b/redis/client.py @@ -960,6 +960,18 @@ def __init__( def __repr__(self): return f"{type(self).__name__}<{repr(self.connection_pool)}>" + def get_encoder(self): + """ + Get the connection pool's encoder + """ + return self.connection_pool.get_encoder() + + def get_connection_kwargs(self): + """ + Get the connection's key-word arguments + """ + return self.connection_pool.connection_kwargs + def set_response_callback(self, command, callback): "Set a custom Response Callback" self.response_callbacks[command] = callback diff --git a/redis/cluster.py b/redis/cluster.py index 57e8316ba2..eead2b4dfe 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -8,7 +8,7 @@ from collections import OrderedDict from redis.client import CaseInsensitiveDict, PubSub, Redis -from redis.commands import ClusterCommands, CommandsParser +from redis.commands import CommandsParser, RedisClusterCommands from redis.connection import ConnectionPool, DefaultParser, Encoder, parse_url from redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot from redis.exceptions import ( @@ -94,6 +94,7 @@ def fix_server(*args): "charset", "connection_class", "connection_pool", + "client_name", "db", "decode_responses", "encoding", @@ -198,7 +199,7 @@ class ClusterParser(DefaultParser): ) -class RedisCluster(ClusterCommands): +class RedisCluster(RedisClusterCommands): RedisClusterRequestTTL = 16 PRIMARIES = "primaries" @@ -212,6 +213,18 @@ class RedisCluster(ClusterCommands): COMMAND_FLAGS = dict_merge( list_keys_to_dict( [ + "ACL CAT", + "ACL DELUSER", + "ACL GENPASS", + "ACL GETUSER", + "ACL HELP", + "ACL LIST", + "ACL LOG", + "ACL LOAD", + "ACL SAVE", + "ACL SETUSER", + "ACL USERS", + "ACL WHOAMI", "CLIENT LIST", "CLIENT SETNAME", "CLIENT GETNAME", @@ -770,6 +783,18 @@ def determine_slot(self, *args): def reinitialize_caches(self): self.nodes_manager.initialize() + def get_encoder(self): + """ + Get the connections' encoder + """ + return self.encoder + + def get_connection_kwargs(self): + """ + Get the connections' key-word arguments + """ + return self.nodes_manager.connection_kwargs + def _is_nodes_flag(self, target_nodes): return isinstance(target_nodes, str) and target_nodes in self.node_flags @@ -1383,7 +1408,8 @@ def initialize(self): # isn't a full coverage raise RedisClusterException( f"All slots are not covered after query all startup_nodes. " - f"{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} covered..." + f"{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} " + f"covered..." ) elif not fully_covered and not self._require_full_coverage: # The user set require_full_coverage to False. @@ -1402,7 +1428,8 @@ def initialize(self): "cluster-require-full-coverage configuration to no on " "all of the cluster nodes if you wish the cluster to " "be able to serve without being fully covered." - f"{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} covered..." + f"{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} " + f"covered..." ) # Set the tmp variables to the real variables @@ -1950,8 +1977,8 @@ def block_pipeline_command(func): def inner(*args, **kwargs): raise RedisClusterException( - f"ERROR: Calling pipelined function {func.__name__} is blocked when " - f"running redis in cluster mode..." + f"ERROR: Calling pipelined function {func.__name__} is blocked " + f"when running redis in cluster mode..." ) return inner diff --git a/redis/commands/__init__.py b/redis/commands/__init__.py index bc1e78c60c..07fa7f1431 100644 --- a/redis/commands/__init__.py +++ b/redis/commands/__init__.py @@ -1,4 +1,4 @@ -from .cluster import ClusterCommands +from .cluster import RedisClusterCommands from .core import CoreCommands from .helpers import list_or_args from .parser import CommandsParser @@ -6,7 +6,7 @@ from .sentinel import SentinelCommands __all__ = [ - "ClusterCommands", + "RedisClusterCommands", "CommandsParser", "CoreCommands", "list_or_args", diff --git a/redis/commands/cluster.py b/redis/commands/cluster.py index 0df073ab16..5d0e804628 100644 --- a/redis/commands/cluster.py +++ b/redis/commands/cluster.py @@ -1,7 +1,7 @@ from redis.crc import key_slot -from redis.exceptions import ConnectionError, DataError, RedisError +from redis.exceptions import RedisClusterException, RedisError -from .core import DataAccessCommands +from .core import ACLCommands, DataAccessCommands, ManagementCommands, PubSubCommands from .helpers import list_or_args @@ -144,451 +144,31 @@ def unlink(self, *keys): return self._split_command_across_slots("UNLINK", *keys) -class ClusterManagementCommands: +class ClusterManagementCommands(ManagementCommands): """ - Redis Cluster management commands + A class for Redis Cluster management commands - Commands with the 'target_nodes' argument can be executed on specified - nodes. By default, if target_nodes is not specified, the command will be - executed on the default cluster node. - - :param :target_nodes: type can be one of the followings: - - nodes flag: 'all', 'primaries', 'replicas', 'random' - - 'ClusterNode' - - 'list(ClusterNodes)' - - 'dict(any:clusterNodes)' - - for example: - primary = r.get_primaries()[0] - r.bgsave(target_nodes=primary) - r.bgsave(target_nodes='primaries') + The class inherits from Redis's core ManagementCommands class and do the + required adjustments to work with cluster mode """ - def bgsave(self, schedule=True, target_nodes=None): - """ - Tell the Redis server to save its data to disk. Unlike save(), - this method is asynchronous and returns immediately. - """ - pieces = [] - if schedule: - pieces.append("SCHEDULE") - return self.execute_command("BGSAVE", *pieces, target_nodes=target_nodes) - - def client_getname(self, target_nodes=None): - """ - Returns the current connection name from all nodes. - The result will be a dictionary with the IP and - connection name. - """ - return self.execute_command("CLIENT GETNAME", target_nodes=target_nodes) - - def client_getredir(self, target_nodes=None): - """Returns the ID (an integer) of the client to whom we are - redirecting tracking notifications. - - see: https://redis.io/commands/client-getredir - """ - return self.execute_command("CLIENT GETREDIR", target_nodes=target_nodes) - - def client_id(self, target_nodes=None): - """Returns the current connection id""" - return self.execute_command("CLIENT ID", target_nodes=target_nodes) - - def client_info(self, target_nodes=None): - """ - Returns information and statistics about the current - client connection. - """ - return self.execute_command("CLIENT INFO", target_nodes=target_nodes) - - def client_kill_filter( - self, - _id=None, - _type=None, - addr=None, - skipme=None, - laddr=None, - user=None, - target_nodes=None, - ): - """ - Disconnects client(s) using a variety of filter options - :param id: Kills a client by its unique ID field - :param type: Kills a client by type where type is one of 'normal', - 'master', 'slave' or 'pubsub' - :param addr: Kills a client by its 'address:port' - :param skipme: If True, then the client calling the command - will not get killed even if it is identified by one of the filter - options. If skipme is not provided, the server defaults to skipme=True - :param laddr: Kills a client by its 'local (bind) address:port' - :param user: Kills a client for a specific user name - """ - args = [] - if _type is not None: - client_types = ("normal", "master", "slave", "pubsub") - if str(_type).lower() not in client_types: - raise DataError(f"CLIENT KILL type must be one of {client_types!r}") - args.extend((b"TYPE", _type)) - if skipme is not None: - if not isinstance(skipme, bool): - raise DataError("CLIENT KILL skipme must be a bool") - if skipme: - args.extend((b"SKIPME", b"YES")) - else: - args.extend((b"SKIPME", b"NO")) - if _id is not None: - args.extend((b"ID", _id)) - if addr is not None: - args.extend((b"ADDR", addr)) - if laddr is not None: - args.extend((b"LADDR", laddr)) - if user is not None: - args.extend((b"USER", user)) - if not args: - raise DataError( - "CLIENT KILL ... ... " - " must specify at least one filter" - ) - return self.execute_command("CLIENT KILL", *args, target_nodes=target_nodes) - - def client_kill(self, address, target_nodes=None): - "Disconnects the client at ``address`` (ip:port)" - return self.execute_command("CLIENT KILL", address, target_nodes=target_nodes) - - def client_list(self, _type=None, target_nodes=None): - """ - Returns a list of currently connected clients to the entire cluster. - If type of client specified, only that type will be returned. - :param _type: optional. one of the client types (normal, master, - replica, pubsub) - """ - if _type is not None: - client_types = ("normal", "master", "replica", "pubsub") - if str(_type).lower() not in client_types: - raise DataError(f"CLIENT LIST _type must be one of {client_types!r}") - return self.execute_command( - "CLIENT LIST", b"TYPE", _type, target_noes=target_nodes - ) - return self.execute_command("CLIENT LIST", target_nodes=target_nodes) - - def client_pause(self, timeout, target_nodes=None): - """ - Suspend all the Redis clients for the specified amount of time - :param timeout: milliseconds to pause clients - """ - if not isinstance(timeout, int): - raise DataError("CLIENT PAUSE timeout must be an integer") - return self.execute_command( - "CLIENT PAUSE", str(timeout), target_nodes=target_nodes - ) - - def client_reply(self, reply, target_nodes=None): - """Enable and disable redis server replies. - ``reply`` Must be ON OFF or SKIP, - ON - The default most with server replies to commands - OFF - Disable server responses to commands - SKIP - Skip the response of the immediately following command. - - Note: When setting OFF or SKIP replies, you will need a client object - with a timeout specified in seconds, and will need to catch the - TimeoutError. - The test_client_reply unit test illustrates this, and - conftest.py has a client with a timeout. - See https://redis.io/commands/client-reply - """ - replies = ["ON", "OFF", "SKIP"] - if reply not in replies: - raise DataError(f"CLIENT REPLY must be one of {replies!r}") - return self.execute_command("CLIENT REPLY", reply, target_nodes=target_nodes) - - def client_setname(self, name, target_nodes=None): - "Sets the current connection name" - return self.execute_command("CLIENT SETNAME", name, target_nodes=target_nodes) - - def client_trackinginfo(self, target_nodes=None): - """ - Returns the information about the current client connection's - use of the server assisted client side cache. - See https://redis.io/commands/client-trackinginfo - """ - return self.execute_command("CLIENT TRACKINGINFO", target_nodes=target_nodes) - - def client_unblock(self, client_id, error=False, target_nodes=None): - """ - Unblocks a connection by its client id. - If ``error`` is True, unblocks the client with a special error message. - If ``error`` is False (default), the client is unblocked using the - regular timeout mechanism. - """ - args = ["CLIENT UNBLOCK", int(client_id)] - if error: - args.append(b"ERROR") - return self.execute_command(*args, target_nodes=target_nodes) - - def client_unpause(self, target_nodes=None): - """ - Unpause all redis clients - """ - return self.execute_command("CLIENT UNPAUSE", target_nodes=target_nodes) - - def command(self, target_nodes=None): - """ - Returns dict reply of details about all Redis commands. - """ - return self.execute_command("COMMAND", target_nodes=target_nodes) - - def command_count(self, target_nodes=None): - """ - Returns Integer reply of number of total commands in this Redis server. - """ - return self.execute_command("COMMAND COUNT", target_nodes=target_nodes) - - def config_get(self, pattern="*", target_nodes=None): - """ - Return a dictionary of configuration based on the ``pattern`` - """ - return self.execute_command("CONFIG GET", pattern, target_nodes=target_nodes) - - def config_resetstat(self, target_nodes=None): - """Reset runtime statistics""" - return self.execute_command("CONFIG RESETSTAT", target_nodes=target_nodes) - - def config_rewrite(self, target_nodes=None): - """ - Rewrite config file with the minimal change to reflect running config. - """ - return self.execute_command("CONFIG REWRITE", target_nodes=target_nodes) - - def config_set(self, name, value, target_nodes=None): - "Set config item ``name`` with ``value``" - return self.execute_command( - "CONFIG SET", name, value, target_nodes=target_nodes - ) - - def dbsize(self, target_nodes=None): - """ - Sums the number of keys in the target nodes' DB. - - :target_nodes: 'ClusterNode' or 'list(ClusterNodes)' - The node/s to execute the command on - """ - return self.execute_command("DBSIZE", target_nodes=target_nodes) - - def debug_object(self, key): - raise NotImplementedError( - "DEBUG OBJECT is intentionally not implemented in the client." - ) - - def debug_segfault(self): - raise NotImplementedError( - "DEBUG SEGFAULT is intentionally not implemented in the client." - ) - - def echo(self, value, target_nodes): - """Echo the string back from the server""" - return self.execute_command("ECHO", value, target_nodes=target_nodes) - - def flushall(self, asynchronous=False, target_nodes=None): - """ - Delete all keys in the database. - In cluster mode this method is the same as flushdb - - ``asynchronous`` indicates whether the operation is - executed asynchronously by the server. - """ - args = [] - if asynchronous: - args.append(b"ASYNC") - return self.execute_command("FLUSHALL", *args, target_nodes=target_nodes) - - def flushdb(self, asynchronous=False, target_nodes=None): - """ - Delete all keys in the database. - - ``asynchronous`` indicates whether the operation is - executed asynchronously by the server. - """ - args = [] - if asynchronous: - args.append(b"ASYNC") - return self.execute_command("FLUSHDB", *args, target_nodes=target_nodes) - - def info(self, section=None, target_nodes=None): - """ - Returns a dictionary containing information about the Redis server - - The ``section`` option can be used to select a specific section - of information - - The section option is not supported by older versions of Redis Server, - and will generate ResponseError - """ - if section is None: - return self.execute_command("INFO", target_nodes=target_nodes) - else: - return self.execute_command("INFO", section, target_nodes=target_nodes) - - def keys(self, pattern="*", target_nodes=None): - "Returns a list of keys matching ``pattern``" - return self.execute_command("KEYS", pattern, target_nodes=target_nodes) - - def lastsave(self, target_nodes=None): - """ - Return a Python datetime object representing the last time the - Redis database was saved to disk - """ - return self.execute_command("LASTSAVE", target_nodes=target_nodes) - - def memory_doctor(self): - raise NotImplementedError( - "MEMORY DOCTOR is intentionally not implemented in the client." - ) - - def memory_help(self): - raise NotImplementedError( - "MEMORY HELP is intentionally not implemented in the client." - ) - - def memory_malloc_stats(self, target_nodes=None): - """Return an internal statistics report from the memory allocator.""" - return self.execute_command("MEMORY MALLOC-STATS", target_nodes=target_nodes) - - def memory_purge(self, target_nodes=None): - """Attempts to purge dirty pages for reclamation by allocator""" - return self.execute_command("MEMORY PURGE", target_nodes=target_nodes) + def slaveof(self, *args, **kwargs): + raise RedisClusterException("SLAVEOF is not supported in cluster mode") - def memory_stats(self, target_nodes=None): - """Return a dictionary of memory stats""" - return self.execute_command("MEMORY STATS", target_nodes=target_nodes) + def replicaof(self, *args, **kwargs): + raise RedisClusterException("REPLICAOF is not supported in cluster" " mode") - def memory_usage(self, key, samples=None): - """ - Return the total memory usage for key, its value and associated - administrative overheads. - - For nested data structures, ``samples`` is the number of elements to - sample. If left unspecified, the server's default is 5. Use 0 to sample - all elements. - """ - args = [] - if isinstance(samples, int): - args.extend([b"SAMPLES", samples]) - return self.execute_command("MEMORY USAGE", key, *args) - - def object(self, infotype, key): - """Return the encoding, idletime, or refcount about the key""" - return self.execute_command("OBJECT", infotype, key, infotype=infotype) - - def ping(self, target_nodes=None): - """ - Ping the cluster's servers. - If no target nodes are specified, sent to all nodes and returns True if - the ping was successful across all nodes. - """ - return self.execute_command("PING", target_nodes=target_nodes) - - def randomkey(self, target_nodes=None): - """ - Returns the name of a random key" - """ - return self.execute_command("RANDOMKEY", target_nodes=target_nodes) + def swapdb(self, *args, **kwargs): + raise RedisClusterException("SWAPDB is not supported in cluster" " mode") - def save(self, target_nodes=None): - """ - Tell the Redis server to save its data to disk, - blocking until the save is complete - """ - return self.execute_command("SAVE", target_nodes=target_nodes) - - def scan(self, cursor=0, match=None, count=None, _type=None, target_nodes=None): - """ - Incrementally return lists of key names. Also return a cursor - indicating the scan position. - - ``match`` allows for filtering the keys by pattern - - ``count`` provides a hint to Redis about the number of keys to - return per batch. - - ``_type`` filters the returned values by a particular Redis type. - Stock Redis instances allow for the following types: - HASH, LIST, SET, STREAM, STRING, ZSET - Additionally, Redis modules can expose other types as well. - """ - pieces = [cursor] - if match is not None: - pieces.extend([b"MATCH", match]) - if count is not None: - pieces.extend([b"COUNT", count]) - if _type is not None: - pieces.extend([b"TYPE", _type]) - return self.execute_command("SCAN", *pieces, target_nodes=target_nodes) - - def scan_iter(self, match=None, count=None, _type=None, target_nodes=None): - """ - Make an iterator using the SCAN command so that the client doesn't - need to remember the cursor position. - ``match`` allows for filtering the keys by pattern - - ``count`` provides a hint to Redis about the number of keys to - return per batch. +class ClusterDataAccessCommands(DataAccessCommands): + """ + A class for Redis Cluster Data Access Commands - ``_type`` filters the returned values by a particular Redis type. - Stock Redis instances allow for the following types: - HASH, LIST, SET, STREAM, STRING, ZSET - Additionally, Redis modules can expose other types as well. - """ - cursor = "0" - while cursor != 0: - cursor, data = self.scan( - cursor=cursor, - match=match, - count=count, - _type=_type, - target_nodes=target_nodes, - ) - yield from data - - def shutdown(self, save=False, nosave=False, target_nodes=None): - """Shutdown the Redis server. If Redis has persistence configured, - data will be flushed before shutdown. If the "save" option is set, - a data flush will be attempted even if there is no persistence - configured. If the "nosave" option is set, no data flush will be - attempted. The "save" and "nosave" options cannot both be set. - """ - if save and nosave: - raise DataError("SHUTDOWN save and nosave cannot both be set") - args = ["SHUTDOWN"] - if save: - args.append("SAVE") - if nosave: - args.append("NOSAVE") - try: - self.execute_command(*args, target_nodes=target_nodes) - except ConnectionError: - # a ConnectionError here is expected - return - raise RedisError("SHUTDOWN seems to have failed.") - - def slowlog_get(self, num=None, target_nodes=None): - """ - Get the entries from the slowlog. If ``num`` is specified, get the - most recent ``num`` items. - """ - args = ["SLOWLOG GET"] - if num is not None: - args.append(num) - - return self.execute_command(*args, target_nodes=target_nodes) - - def slowlog_len(self, target_nodes=None): - "Get the number of items in the slowlog" - return self.execute_command("SLOWLOG LEN", target_nodes=target_nodes) - - def slowlog_reset(self, target_nodes=None): - "Remove all items in the slowlog" - return self.execute_command("SLOWLOG RESET", target_nodes=target_nodes) + The class inherits from Redis's core DataAccessCommand class and do the + required adjustments to work with cluster mode + """ def stralgo( self, @@ -600,138 +180,50 @@ def stralgo( idx=False, minmatchlen=None, withmatchlen=False, - target_nodes=None, + **kwargs, ): - """ - Implements complex algorithms that operate on strings. - Right now the only algorithm implemented is the LCS algorithm - (longest common substring). However new algorithms could be - implemented in the future. - - ``algo`` Right now must be LCS - ``value1`` and ``value2`` Can be two strings or two keys - ``specific_argument`` Specifying if the arguments to the algorithm - will be keys or strings. strings is the default. - ``len`` Returns just the len of the match. - ``idx`` Returns the match positions in each string. - ``minmatchlen`` Restrict the list of matches to the ones of a given - minimal length. Can be provided only when ``idx`` set to True. - ``withmatchlen`` Returns the matches with the len of the match. - Can be provided only when ``idx`` set to True. - """ - # check validity - supported_algo = ["LCS"] - if algo not in supported_algo: - supported_algos_str = ", ".join(supported_algo) - raise DataError(f"The supported algorithms are: {supported_algos_str}") - if specific_argument not in ["keys", "strings"]: - raise DataError("specific_argument can be only keys or strings") - if len and idx: - raise DataError("len and idx cannot be provided together.") - - pieces = [algo, specific_argument.upper(), value1, value2] - if len: - pieces.append(b"LEN") - if idx: - pieces.append(b"IDX") - try: - int(minmatchlen) - pieces.extend([b"MINMATCHLEN", minmatchlen]) - except TypeError: - pass - if withmatchlen: - pieces.append(b"WITHMATCHLEN") + target_nodes = kwargs.pop("target_nodes", None) if specific_argument == "strings" and target_nodes is None: target_nodes = "default-node" - return self.execute_command( - "STRALGO", - *pieces, - len=len, - idx=idx, - minmatchlen=minmatchlen, - withmatchlen=withmatchlen, - target_nodes=target_nodes, - ) - - def time(self, target_nodes=None): - """ - Returns the server time as a 2-item tuple of ints: - (seconds since epoch, microseconds into this second). - """ - return self.execute_command("TIME", target_nodes=target_nodes) - - def wait(self, num_replicas, timeout, target_nodes=None): - """ - Redis synchronous replication - That returns the number of replicas that processed the query when - we finally have at least ``num_replicas``, or when the ``timeout`` was - reached. - - If more than one target node are passed the result will be summed up - """ - return self.execute_command( - "WAIT", num_replicas, timeout, target_nodes=target_nodes + kwargs.update({"target_nodes": target_nodes}) + return super().stralgo( + algo, + value1, + value2, + specific_argument, + len, + idx, + minmatchlen, + withmatchlen, + **kwargs, ) -class ClusterPubSubCommands: - """ - Redis PubSub commands for RedisCluster use. - see https://redis.io/topics/pubsub - """ - - def publish(self, channel, message, target_nodes=None): - """ - Publish ``message`` on ``channel``. - Returns the number of subscribers the message was delivered to. - """ - return self.execute_command( - "PUBLISH", channel, message, target_nodes=target_nodes - ) - - def pubsub_channels(self, pattern="*", target_nodes=None): - """ - Return a list of channels that have at least one subscriber - """ - return self.execute_command( - "PUBSUB CHANNELS", pattern, target_nodes=target_nodes - ) - - def pubsub_numpat(self, target_nodes=None): - """ - Returns the number of subscriptions to patterns - """ - return self.execute_command("PUBSUB NUMPAT", target_nodes=target_nodes) - - def pubsub_numsub(self, *args, target_nodes=None): - """ - Return a list of (channel, number of subscribers) tuples - for each channel given in ``*args`` - """ - return self.execute_command("PUBSUB NUMSUB", *args, target_nodes=target_nodes) - - -class ClusterCommands( - ClusterManagementCommands, +class RedisClusterCommands( ClusterMultiKeyCommands, - ClusterPubSubCommands, - DataAccessCommands, + ClusterManagementCommands, + ACLCommands, + PubSubCommands, + ClusterDataAccessCommands, ): """ - Redis Cluster commands + A class for all Redis Cluster commands + + For key-based commands, the target node(s) will be internally determined + by the keys' hash slot. + Non-key-based commands can be executed with the 'target_nodes' argument to + target specific nodes. By default, if target_nodes is not specified, the + command will be executed on the default cluster node. - Commands with the 'target_nodes' argument can be executed on specified - nodes. By default, if target_nodes is not specified, the command will be - executed on the default cluster node. :param :target_nodes: type can be one of the followings: - - nodes flag: 'all', 'primaries', 'replicas', 'random' + - nodes flag: ALL_NODES, PRIMARIES, REPLICAS, RANDOM - 'ClusterNode' - 'list(ClusterNodes)' - 'dict(any:clusterNodes)' for example: - r.cluster_info(target_nodes='all') + r.cluster_info(target_nodes=RedisCluster.ALL_NODES) """ def cluster_addslots(self, target_node, *slots): diff --git a/redis/commands/core.py b/redis/commands/core.py index 688e1dda1b..462fba7f82 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -14,7 +14,7 @@ class ACLCommands: see: https://redis.io/topics/acl """ - def acl_cat(self, category=None): + def acl_cat(self, category=None, **kwargs): """ Returns a list of categories or commands within a category. @@ -25,17 +25,17 @@ def acl_cat(self, category=None): For more information check https://redis.io/commands/acl-cat """ pieces = [category] if category else [] - return self.execute_command("ACL CAT", *pieces) + return self.execute_command("ACL CAT", *pieces, **kwargs) - def acl_deluser(self, *username): + def acl_deluser(self, *username, **kwargs): """ Delete the ACL for the specified ``username``s For more information check https://redis.io/commands/acl-deluser """ - return self.execute_command("ACL DELUSER", *username) + return self.execute_command("ACL DELUSER", *username, **kwargs) - def acl_genpass(self, bits=None): + def acl_genpass(self, bits=None, **kwargs): """Generate a random password value. If ``bits`` is supplied then use this number of bits, rounded to the next multiple of 4. @@ -51,9 +51,9 @@ def acl_genpass(self, bits=None): raise DataError( "genpass optionally accepts a bits argument, " "between 0 and 4096." ) - return self.execute_command("ACL GENPASS", *pieces) + return self.execute_command("ACL GENPASS", *pieces, **kwargs) - def acl_getuser(self, username): + def acl_getuser(self, username, **kwargs): """ Get the ACL details for the specified ``username``. @@ -61,25 +61,25 @@ def acl_getuser(self, username): For more information check https://redis.io/commands/acl-getuser """ - return self.execute_command("ACL GETUSER", username) + return self.execute_command("ACL GETUSER", username, **kwargs) - def acl_help(self): + def acl_help(self, **kwargs): """The ACL HELP command returns helpful text describing the different subcommands. For more information check https://redis.io/commands/acl-help """ - return self.execute_command("ACL HELP") + return self.execute_command("ACL HELP", **kwargs) - def acl_list(self): + def acl_list(self, **kwargs): """ Return a list of all ACLs on the server For more information check https://redis.io/commands/acl-list """ - return self.execute_command("ACL LIST") + return self.execute_command("ACL LIST", **kwargs) - def acl_log(self, count=None): + def acl_log(self, count=None, **kwargs): """ Get ACL logs as a list. :param int count: Get logs[0:count]. @@ -93,9 +93,9 @@ def acl_log(self, count=None): raise DataError("ACL LOG count must be an " "integer") args.append(count) - return self.execute_command("ACL LOG", *args) + return self.execute_command("ACL LOG", *args, **kwargs) - def acl_log_reset(self): + def acl_log_reset(self, **kwargs): """ Reset ACL logs. :rtype: Boolean. @@ -103,9 +103,9 @@ def acl_log_reset(self): For more information check https://redis.io/commands/acl-log """ args = [b"RESET"] - return self.execute_command("ACL LOG", *args) + return self.execute_command("ACL LOG", *args, **kwargs) - def acl_load(self): + def acl_load(self, **kwargs): """ Load ACL rules from the configured ``aclfile``. @@ -114,9 +114,9 @@ def acl_load(self): For more information check https://redis.io/commands/acl-load """ - return self.execute_command("ACL LOAD") + return self.execute_command("ACL LOAD", **kwargs) - def acl_save(self): + def acl_save(self, **kwargs): """ Save ACL rules to the configured ``aclfile``. @@ -125,7 +125,7 @@ def acl_save(self): For more information check https://redis.io/commands/acl-save """ - return self.execute_command("ACL SAVE") + return self.execute_command("ACL SAVE", **kwargs) def acl_setuser( self, @@ -140,6 +140,7 @@ def acl_setuser( reset=False, reset_keys=False, reset_passwords=False, + **kwargs, ): """ Create or update an ACL user. @@ -202,7 +203,7 @@ def acl_setuser( For more information check https://redis.io/commands/acl-setuser """ - encoder = self.connection_pool.get_encoder() + encoder = self.get_encoder() pieces = [username] if reset: @@ -291,21 +292,21 @@ def acl_setuser( key = encoder.encode(key) pieces.append(b"~%s" % key) - return self.execute_command("ACL SETUSER", *pieces) + return self.execute_command("ACL SETUSER", *pieces, **kwargs) - def acl_users(self): + def acl_users(self, **kwargs): """Returns a list of all registered users on the server. For more information check https://redis.io/commands/acl-users """ - return self.execute_command("ACL USERS") + return self.execute_command("ACL USERS", **kwargs) - def acl_whoami(self): + def acl_whoami(self, **kwargs): """Get the username for the current connection For more information check https://redis.io/commands/acl-whoami """ - return self.execute_command("ACL WHOAMI") + return self.execute_command("ACL WHOAMI", **kwargs) class ManagementCommands: @@ -313,14 +314,14 @@ class ManagementCommands: Redis management commands """ - def bgrewriteaof(self): + def bgrewriteaof(self, **kwargs): """Tell the Redis server to rewrite the AOF file from data in memory. For more information check https://redis.io/commands/bgrewriteaof """ - return self.execute_command("BGREWRITEAOF") + return self.execute_command("BGREWRITEAOF", **kwargs) - def bgsave(self, schedule=True): + def bgsave(self, schedule=True, **kwargs): """ Tell the Redis server to save its data to disk. Unlike save(), this method is asynchronous and returns immediately. @@ -330,17 +331,24 @@ def bgsave(self, schedule=True): pieces = [] if schedule: pieces.append("SCHEDULE") - return self.execute_command("BGSAVE", *pieces) + return self.execute_command("BGSAVE", *pieces, **kwargs) - def client_kill(self, address): + def client_kill(self, address, **kwargs): """Disconnects the client at ``address`` (ip:port) For more information check https://redis.io/commands/client-kill """ - return self.execute_command("CLIENT KILL", address) + return self.execute_command("CLIENT KILL", address, **kwargs) def client_kill_filter( - self, _id=None, _type=None, addr=None, skipme=None, laddr=None, user=None + self, + _id=None, + _type=None, + addr=None, + skipme=None, + laddr=None, + user=None, + **kwargs, ): """ Disconnects client(s) using a variety of filter options @@ -380,18 +388,18 @@ def client_kill_filter( "CLIENT KILL ... ... " " must specify at least one filter" ) - return self.execute_command("CLIENT KILL", *args) + return self.execute_command("CLIENT KILL", *args, **kwargs) - def client_info(self): + def client_info(self, **kwargs): """ Returns information and statistics about the current client connection. For more information check https://redis.io/commands/client-info """ - return self.execute_command("CLIENT INFO") + return self.execute_command("CLIENT INFO", **kwargs) - def client_list(self, _type=None, client_id=[]): + def client_list(self, _type=None, client_id=[], **kwargs): """ Returns a list of currently connected clients. If type of client specified, only that type will be returned. @@ -413,26 +421,26 @@ def client_list(self, _type=None, client_id=[]): if client_id != []: args.append(b"ID") args.append(" ".join(client_id)) - return self.execute_command("CLIENT LIST", *args) + return self.execute_command("CLIENT LIST", *args, **kwargs) - def client_getname(self): + def client_getname(self, **kwargs): """ Returns the current connection name For more information check https://redis.io/commands/client-getname """ - return self.execute_command("CLIENT GETNAME") + return self.execute_command("CLIENT GETNAME", **kwargs) - def client_getredir(self): + def client_getredir(self, **kwargs): """ Returns the ID (an integer) of the client to whom we are redirecting tracking notifications. see: https://redis.io/commands/client-getredir """ - return self.execute_command("CLIENT GETREDIR") + return self.execute_command("CLIENT GETREDIR", **kwargs) - def client_reply(self, reply): + def client_reply(self, reply, **kwargs): """ Enable and disable redis server replies. ``reply`` Must be ON OFF or SKIP, @@ -451,34 +459,34 @@ def client_reply(self, reply): replies = ["ON", "OFF", "SKIP"] if reply not in replies: raise DataError(f"CLIENT REPLY must be one of {replies!r}") - return self.execute_command("CLIENT REPLY", reply) + return self.execute_command("CLIENT REPLY", reply, **kwargs) - def client_id(self): + def client_id(self, **kwargs): """ Returns the current connection id For more information check https://redis.io/commands/client-id """ - return self.execute_command("CLIENT ID") + return self.execute_command("CLIENT ID", **kwargs) - def client_trackinginfo(self): + def client_trackinginfo(self, **kwargs): """ Returns the information about the current client connection's use of the server assisted client side cache. See https://redis.io/commands/client-trackinginfo """ - return self.execute_command("CLIENT TRACKINGINFO") + return self.execute_command("CLIENT TRACKINGINFO", **kwargs) - def client_setname(self, name): + def client_setname(self, name, **kwargs): """ Sets the current connection name For more information check https://redis.io/commands/client-setname """ - return self.execute_command("CLIENT SETNAME", name) + return self.execute_command("CLIENT SETNAME", name, **kwargs) - def client_unblock(self, client_id, error=False): + def client_unblock(self, client_id, error=False, **kwargs): """ Unblocks a connection by its client id. If ``error`` is True, unblocks the client with a special error message. @@ -490,9 +498,9 @@ def client_unblock(self, client_id, error=False): args = ["CLIENT UNBLOCK", int(client_id)] if error: args.append(b"ERROR") - return self.execute_command(*args) + return self.execute_command(*args, **kwargs) - def client_pause(self, timeout): + def client_pause(self, timeout, **kwargs): """ Suspend all the Redis clients for the specified amount of time :param timeout: milliseconds to pause clients @@ -501,91 +509,80 @@ def client_pause(self, timeout): """ if not isinstance(timeout, int): raise DataError("CLIENT PAUSE timeout must be an integer") - return self.execute_command("CLIENT PAUSE", str(timeout)) + return self.execute_command("CLIENT PAUSE", str(timeout), **kwargs) - def client_unpause(self): + def client_unpause(self, **kwargs): """ Unpause all redis clients For more information check https://redis.io/commands/client-unpause """ - return self.execute_command("CLIENT UNPAUSE") - - def command_info(self): - raise NotImplementedError( - "COMMAND INFO is intentionally not implemented in the client." - ) + return self.execute_command("CLIENT UNPAUSE", **kwargs) - def command_count(self): - return self.execute_command("COMMAND COUNT") - - def readwrite(self): + def command(self, **kwargs): """ - Disables read queries for a connection to a Redis Cluster slave node. + Returns dict reply of details about all Redis commands. - For more information check https://redis.io/commands/readwrite + For more information check https://redis.io/commands/command """ - return self.execute_command("READWRITE") + return self.execute_command("COMMAND", **kwargs) - def readonly(self): - """ - Enables read queries for a connection to a Redis Cluster replica node. + def command_info(self, **kwargs): + raise NotImplementedError( + "COMMAND INFO is intentionally not implemented in the client." + ) - For more information check https://redis.io/commands/readonly - """ - return self.execute_command("READONLY") + def command_count(self, **kwargs): + return self.execute_command("COMMAND COUNT", **kwargs) - def config_get(self, pattern="*"): + def config_get(self, pattern="*", **kwargs): """ Return a dictionary of configuration based on the ``pattern`` For more information check https://redis.io/commands/config-get """ - return self.execute_command("CONFIG GET", pattern) + return self.execute_command("CONFIG GET", pattern, **kwargs) - def config_set(self, name, value): + def config_set(self, name, value, **kwargs): """Set config item ``name`` with ``value`` For more information check https://redis.io/commands/config-set """ - return self.execute_command("CONFIG SET", name, value) + return self.execute_command("CONFIG SET", name, value, **kwargs) - def config_resetstat(self): + def config_resetstat(self, **kwargs): """ Reset runtime statistics For more information check https://redis.io/commands/config-resetstat """ - return self.execute_command("CONFIG RESETSTAT") + return self.execute_command("CONFIG RESETSTAT", **kwargs) - def config_rewrite(self): + def config_rewrite(self, **kwargs): """ Rewrite config file with the minimal change to reflect running config. For more information check https://redis.io/commands/config-rewrite """ - return self.execute_command("CONFIG REWRITE") + return self.execute_command("CONFIG REWRITE", **kwargs) - def cluster(self, cluster_arg, *args): - return self.execute_command(f"CLUSTER {cluster_arg.upper()}", *args) - - def dbsize(self): + def dbsize(self, **kwargs): """ Returns the number of keys in the current database For more information check https://redis.io/commands/dbsize """ - return self.execute_command("DBSIZE") + return self.execute_command("DBSIZE", **kwargs) - def debug_object(self, key): + def debug_object(self, key, **kwargs): """ Returns version specific meta information about a given key For more information check https://redis.io/commands/debug-object """ - return self.execute_command("DEBUG OBJECT", key) + return self.execute_command("DEBUG OBJECT", key, **kwargs) - def debug_segfault(self): + def debug_segfault(self, **kwargs): raise NotImplementedError( """ DEBUG SEGFAULT is intentionally not implemented in the client. @@ -594,15 +591,15 @@ def debug_segfault(self): """ ) - def echo(self, value): + def echo(self, value, **kwargs): """ Echo the string back from the server For more information check https://redis.io/commands/echo """ - return self.execute_command("ECHO", value) + return self.execute_command("ECHO", value, **kwargs) - def flushall(self, asynchronous=False): + def flushall(self, asynchronous=False, **kwargs): """ Delete all keys in all databases on the current host. @@ -614,9 +611,9 @@ def flushall(self, asynchronous=False): args = [] if asynchronous: args.append(b"ASYNC") - return self.execute_command("FLUSHALL", *args) + return self.execute_command("FLUSHALL", *args, **kwargs) - def flushdb(self, asynchronous=False): + def flushdb(self, asynchronous=False, **kwargs): """ Delete all keys in the current database. @@ -628,17 +625,17 @@ def flushdb(self, asynchronous=False): args = [] if asynchronous: args.append(b"ASYNC") - return self.execute_command("FLUSHDB", *args) + return self.execute_command("FLUSHDB", *args, **kwargs) - def swapdb(self, first, second): + def swapdb(self, first, second, **kwargs): """ Swap two databases For more information check https://redis.io/commands/swapdb """ - return self.execute_command("SWAPDB", first, second) + return self.execute_command("SWAPDB", first, second, **kwargs) - def info(self, section=None): + def info(self, section=None, **kwargs): """ Returns a dictionary containing information about the Redis server @@ -651,29 +648,29 @@ def info(self, section=None): For more information check https://redis.io/commands/info """ if section is None: - return self.execute_command("INFO") + return self.execute_command("INFO", **kwargs) else: - return self.execute_command("INFO", section) + return self.execute_command("INFO", section, **kwargs) - def lastsave(self): + def lastsave(self, **kwargs): """ Return a Python datetime object representing the last time the Redis database was saved to disk For more information check https://redis.io/commands/lastsave """ - return self.execute_command("LASTSAVE") + return self.execute_command("LASTSAVE", **kwargs) - def lolwut(self, *version_numbers): + def lolwut(self, *version_numbers, **kwargs): """ Get the Redis version and a piece of generative computer art See: https://redis.io/commands/lolwut """ if version_numbers: - return self.execute_command("LOLWUT VERSION", *version_numbers) + return self.execute_command("LOLWUT VERSION", *version_numbers, **kwargs) else: - return self.execute_command("LOLWUT") + return self.execute_command("LOLWUT", **kwargs) def migrate( self, @@ -685,6 +682,7 @@ def migrate( copy=False, replace=False, auth=None, + **kwargs, ): """ Migrate 1 or more keys from the current Redis server to a different @@ -719,16 +717,18 @@ def migrate( pieces.append(b"KEYS") pieces.extend(keys) return self.execute_command( - "MIGRATE", host, port, "", destination_db, timeout, *pieces + "MIGRATE", host, port, "", destination_db, timeout, *pieces, **kwargs ) - def object(self, infotype, key): + def object(self, infotype, key, **kwargs): """ Return the encoding, idletime, or refcount about the key """ - return self.execute_command("OBJECT", infotype, key, infotype=infotype) + return self.execute_command( + "OBJECT", infotype, key, infotype=infotype, **kwargs + ) - def memory_doctor(self): + def memory_doctor(self, **kwargs): raise NotImplementedError( """ MEMORY DOCTOR is intentionally not implemented in the client. @@ -737,7 +737,7 @@ def memory_doctor(self): """ ) - def memory_help(self): + def memory_help(self, **kwargs): raise NotImplementedError( """ MEMORY HELP is intentionally not implemented in the client. @@ -746,23 +746,23 @@ def memory_help(self): """ ) - def memory_stats(self): + def memory_stats(self, **kwargs): """ Return a dictionary of memory stats For more information check https://redis.io/commands/memory-stats """ - return self.execute_command("MEMORY STATS") + return self.execute_command("MEMORY STATS", **kwargs) - def memory_malloc_stats(self): + def memory_malloc_stats(self, **kwargs): """ Return an internal statistics report from the memory allocator. See: https://redis.io/commands/memory-malloc-stats """ - return self.execute_command("MEMORY MALLOC-STATS") + return self.execute_command("MEMORY MALLOC-STATS", **kwargs) - def memory_usage(self, key, samples=None): + def memory_usage(self, key, samples=None, **kwargs): """ Return the total memory usage for key, its value and associated administrative overheads. @@ -776,33 +776,33 @@ def memory_usage(self, key, samples=None): args = [] if isinstance(samples, int): args.extend([b"SAMPLES", samples]) - return self.execute_command("MEMORY USAGE", key, *args) + return self.execute_command("MEMORY USAGE", key, *args, **kwargs) - def memory_purge(self): + def memory_purge(self, **kwargs): """ Attempts to purge dirty pages for reclamation by allocator For more information check https://redis.io/commands/memory-purge """ - return self.execute_command("MEMORY PURGE") + return self.execute_command("MEMORY PURGE", **kwargs) - def ping(self): + def ping(self, **kwargs): """ Ping the Redis server For more information check https://redis.io/commands/ping """ - return self.execute_command("PING") + return self.execute_command("PING", **kwargs) - def quit(self): + def quit(self, **kwargs): """ Ask the server to close the connection. For more information check https://redis.io/commands/quit """ - return self.execute_command("QUIT") + return self.execute_command("QUIT", **kwargs) - def replicaof(self, *args): + def replicaof(self, *args, **kwargs): """ Update the replication settings of a redis replica, on the fly. Examples of valid arguments include: @@ -811,18 +811,18 @@ def replicaof(self, *args): For more information check https://redis.io/commands/replicaof """ - return self.execute_command("REPLICAOF", *args) + return self.execute_command("REPLICAOF", *args, **kwargs) - def save(self): + def save(self, **kwargs): """ Tell the Redis server to save its data to disk, blocking until the save is complete For more information check https://redis.io/commands/save """ - return self.execute_command("SAVE") + return self.execute_command("SAVE", **kwargs) - def shutdown(self, save=False, nosave=False): + def shutdown(self, save=False, nosave=False, **kwargs): """Shutdown the Redis server. If Redis has persistence configured, data will be flushed before shutdown. If the "save" option is set, a data flush will be attempted even if there is no persistence @@ -839,13 +839,13 @@ def shutdown(self, save=False, nosave=False): if nosave: args.append("NOSAVE") try: - self.execute_command(*args) + self.execute_command(*args, **kwargs) except ConnectionError: # a ConnectionError here is expected return raise RedisError("SHUTDOWN seems to have failed.") - def slaveof(self, host=None, port=None): + def slaveof(self, host=None, port=None, **kwargs): """ Set the server to be a replicated slave of the instance identified by the ``host`` and ``port``. If called without arguments, the @@ -854,10 +854,10 @@ def slaveof(self, host=None, port=None): For more information check https://redis.io/commands/slaveof """ if host is None and port is None: - return self.execute_command("SLAVEOF", b"NO", b"ONE") - return self.execute_command("SLAVEOF", host, port) + return self.execute_command("SLAVEOF", b"NO", b"ONE", **kwargs) + return self.execute_command("SLAVEOF", host, port, **kwargs) - def slowlog_get(self, num=None): + def slowlog_get(self, num=None, **kwargs): """ Get the entries from the slowlog. If ``num`` is specified, get the most recent ``num`` items. @@ -867,37 +867,35 @@ def slowlog_get(self, num=None): args = ["SLOWLOG GET"] if num is not None: args.append(num) - decode_responses = self.connection_pool.connection_kwargs.get( - "decode_responses", False - ) - return self.execute_command(*args, decode_responses=decode_responses) + decode_responses = self.get_connection_kwargs().get("decode_responses", False) + return self.execute_command(*args, decode_responses=decode_responses, **kwargs) - def slowlog_len(self): + def slowlog_len(self, **kwargs): """ Get the number of items in the slowlog For more information check https://redis.io/commands/slowlog-len """ - return self.execute_command("SLOWLOG LEN") + return self.execute_command("SLOWLOG LEN", **kwargs) - def slowlog_reset(self): + def slowlog_reset(self, **kwargs): """ Remove all items in the slowlog For more information check https://redis.io/commands/slowlog-reset """ - return self.execute_command("SLOWLOG RESET") + return self.execute_command("SLOWLOG RESET", **kwargs) - def time(self): + def time(self, **kwargs): """ Returns the server time as a 2-item tuple of ints: (seconds since epoch, microseconds into this second). For more information check https://redis.io/commands/time """ - return self.execute_command("TIME") + return self.execute_command("TIME", **kwargs) - def wait(self, num_replicas, timeout): + def wait(self, num_replicas, timeout, **kwargs): """ Redis synchronous replication That returns the number of replicas that processed the query when @@ -906,7 +904,7 @@ def wait(self, num_replicas, timeout): For more information check https://redis.io/commands/wait """ - return self.execute_command("WAIT", num_replicas, timeout) + return self.execute_command("WAIT", num_replicas, timeout, **kwargs) class BasicKeyCommands: @@ -1218,13 +1216,13 @@ def incrbyfloat(self, name, amount=1.0): """ return self.execute_command("INCRBYFLOAT", name, amount) - def keys(self, pattern="*"): + def keys(self, pattern="*", **kwargs): """ Returns a list of keys matching ``pattern`` For more information check https://redis.io/commands/keys """ - return self.execute_command("KEYS", pattern) + return self.execute_command("KEYS", pattern, **kwargs) def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): """ @@ -1370,13 +1368,13 @@ def hrandfield(self, key, count=None, withvalues=False): return self.execute_command("HRANDFIELD", key, *params) - def randomkey(self): + def randomkey(self, **kwargs): """ Returns the name of a random key For more information check https://redis.io/commands/randomkey """ - return self.execute_command("RANDOMKEY") + return self.execute_command("RANDOMKEY", **kwargs) def rename(self, src, dst): """ @@ -1587,6 +1585,7 @@ def stralgo( idx=False, minmatchlen=None, withmatchlen=False, + **kwargs, ): """ Implements complex algorithms that operate on strings. @@ -1637,6 +1636,7 @@ def stralgo( idx=idx, minmatchlen=minmatchlen, withmatchlen=withmatchlen, + **kwargs, ) def strlen(self, name): @@ -2028,7 +2028,7 @@ class ScanCommands: see: https://redis.io/commands/scan """ - def scan(self, cursor=0, match=None, count=None, _type=None): + def scan(self, cursor=0, match=None, count=None, _type=None, **kwargs): """ Incrementally return lists of key names. Also return a cursor indicating the scan position. @@ -2052,9 +2052,9 @@ def scan(self, cursor=0, match=None, count=None, _type=None): pieces.extend([b"COUNT", count]) if _type is not None: pieces.extend([b"TYPE", _type]) - return self.execute_command("SCAN", *pieces) + return self.execute_command("SCAN", *pieces, **kwargs) - def scan_iter(self, match=None, count=None, _type=None): + def scan_iter(self, match=None, count=None, _type=None, **kwargs): """ Make an iterator using the SCAN command so that the client doesn't need to remember the cursor position. @@ -2072,7 +2072,7 @@ def scan_iter(self, match=None, count=None, _type=None): cursor = "0" while cursor != 0: cursor, data = self.scan( - cursor=cursor, match=match, count=count, _type=_type + cursor=cursor, match=match, count=count, _type=_type, **kwargs ) yield from data @@ -3677,39 +3677,39 @@ class PubSubCommands: see https://redis.io/topics/pubsub """ - def publish(self, channel, message): + def publish(self, channel, message, **kwargs): """ Publish ``message`` on ``channel``. Returns the number of subscribers the message was delivered to. For more information check https://redis.io/commands/publish """ - return self.execute_command("PUBLISH", channel, message) + return self.execute_command("PUBLISH", channel, message, **kwargs) - def pubsub_channels(self, pattern="*"): + def pubsub_channels(self, pattern="*", **kwargs): """ Return a list of channels that have at least one subscriber For more information check https://redis.io/commands/pubsub-channels """ - return self.execute_command("PUBSUB CHANNELS", pattern) + return self.execute_command("PUBSUB CHANNELS", pattern, **kwargs) - def pubsub_numpat(self): + def pubsub_numpat(self, **kwargs): """ Returns the number of subscriptions to patterns For more information check https://redis.io/commands/pubsub-numpat """ - return self.execute_command("PUBSUB NUMPAT") + return self.execute_command("PUBSUB NUMPAT", **kwargs) - def pubsub_numsub(self, *args): + def pubsub_numsub(self, *args, **kwargs): """ Return a list of (channel, number of subscribers) tuples for each channel given in ``*args`` For more information check https://redis.io/commands/pubsub-numsub """ - return self.execute_command("PUBSUB NUMSUB", *args) + return self.execute_command("PUBSUB NUMSUB", *args, **kwargs) class ScriptCommands: @@ -4399,16 +4399,41 @@ def execute(self): return self.client.execute_command(*command) +class ClusterCommands: + """ + Class for Redis Cluster commands + """ + + def cluster(self, cluster_arg, *args, **kwargs): + return self.execute_command(f"CLUSTER {cluster_arg.upper()}", *args, **kwargs) + + def readwrite(self, **kwargs): + """ + Disables read queries for a connection to a Redis Cluster slave node. + + For more information check https://redis.io/commands/readwrite + """ + return self.execute_command("READWRITE", **kwargs) + + def readonly(self, **kwargs): + """ + Enables read queries for a connection to a Redis Cluster replica node. + + For more information check https://redis.io/commands/readonly + """ + return self.execute_command("READONLY", **kwargs) + + class DataAccessCommands( BasicKeyCommands, + HyperlogCommands, + HashCommands, + GeoCommands, ListCommands, ScanCommands, SetCommands, StreamCommands, SortedSetCommands, - HyperlogCommands, - HashCommands, - GeoCommands, ): """ A class containing all of the implemented data access redis commands. @@ -4418,6 +4443,7 @@ class DataAccessCommands( class CoreCommands( ACLCommands, + ClusterCommands, DataAccessCommands, ManagementCommands, ModuleCommands, diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 84d74bd43b..b76ed80958 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -24,13 +24,19 @@ ClusterDownError, DataError, MovedError, + NoPermissionError, RedisClusterException, RedisError, ) from redis.utils import str_if_bytes from tests.test_pubsub import wait_for_message -from .conftest import _get_client, skip_if_server_version_lt, skip_unless_arch_bits +from .conftest import ( + _get_client, + skip_if_redis_enterprise, + skip_if_server_version_lt, + skip_unless_arch_bits, +) default_host = "127.0.0.1" default_port = 7000 @@ -265,7 +271,7 @@ def test_execute_command_node_flag_primaries(self, r): primaries = r.get_primaries() replicas = r.get_replicas() mock_all_nodes_resp(r, "PONG") - assert r.ping(RedisCluster.PRIMARIES) is True + assert r.ping(target_nodes=RedisCluster.PRIMARIES) is True for primary in primaries: conn = primary.redis_connection.connection assert conn.read_response.called is True @@ -282,7 +288,7 @@ def test_execute_command_node_flag_replicas(self, r): r = get_mocked_redis_client(default_host, default_port) primaries = r.get_primaries() mock_all_nodes_resp(r, "PONG") - assert r.ping(RedisCluster.REPLICAS) is True + assert r.ping(target_nodes=RedisCluster.REPLICAS) is True for replica in replicas: conn = replica.redis_connection.connection assert conn.read_response.called is True @@ -295,7 +301,7 @@ def test_execute_command_node_flag_all_nodes(self, r): Test command execution with nodes flag ALL_NODES """ mock_all_nodes_resp(r, "PONG") - assert r.ping(RedisCluster.ALL_NODES) is True + assert r.ping(target_nodes=RedisCluster.ALL_NODES) is True for node in r.get_nodes(): conn = node.redis_connection.connection assert conn.read_response.called is True @@ -305,7 +311,7 @@ def test_execute_command_node_flag_random(self, r): Test command execution with nodes flag RANDOM """ mock_all_nodes_resp(r, "PONG") - assert r.ping(RedisCluster.RANDOM) is True + assert r.ping(target_nodes=RedisCluster.RANDOM) is True called_count = 0 for node in r.get_nodes(): conn = node.redis_connection.connection @@ -1135,7 +1141,7 @@ def test_lastsave(self, r): def test_cluster_echo(self, r): node = r.get_primaries()[0] - assert r.echo("foo bar", node) == b"foo bar" + assert r.echo("foo bar", target_nodes=node) == b"foo bar" @skip_if_server_version_lt("1.0.0") def test_debug_segfault(self, r): @@ -1764,6 +1770,51 @@ def test_cluster_randomkey(self, r): r[key] = 1 assert r.randomkey(target_nodes=node) in (b"{foo}a", b"{foo}b", b"{foo}c") + @skip_if_server_version_lt("6.0.0") + @skip_if_redis_enterprise + def test_acl_log(self, r, request): + key = "{cache}:" + node = r.get_node_from_key(key) + username = "redis-py-user" + + def teardown(): + r.acl_deluser(username, target_nodes="primaries") + + request.addfinalizer(teardown) + r.acl_setuser( + username, + enabled=True, + reset=True, + commands=["+get", "+set", "+select", "+cluster", "+command"], + keys=["{cache}:*"], + nopass=True, + target_nodes="primaries", + ) + r.acl_log_reset(target_nodes=node) + + user_client = _get_client( + RedisCluster, request, flushdb=False, username=username + ) + + # Valid operation and key + assert user_client.set("{cache}:0", 1) + assert user_client.get("{cache}:0") == b"1" + + # Invalid key + with pytest.raises(NoPermissionError): + user_client.get("{cache}violated_cache:0") + + # Invalid operation + with pytest.raises(NoPermissionError): + user_client.hset("{cache}:0", "hkey", "hval") + + assert isinstance(r.acl_log(target_nodes=node), list) + assert len(r.acl_log(target_nodes=node)) == 2 + assert len(r.acl_log(count=1, target_nodes=node)) == 1 + assert isinstance(r.acl_log(target_nodes=node)[0], dict) + assert "client-info" in r.acl_log(count=1, target_nodes=node)[0] + assert r.acl_log_reset(target_nodes=node) + @pytest.mark.onlycluster class TestNodesManager: diff --git a/tests/test_commands.py b/tests/test_commands.py index 1eb35f8673..7c7d0f3d9e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -71,21 +71,18 @@ def test_command_on_invalid_key_type(self, r): r["a"] # SERVER INFORMATION - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_cat_no_category(self, r): categories = r.acl_cat() assert isinstance(categories, list) assert "read" in categories - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_cat_with_category(self, r): commands = r.acl_cat("read") assert isinstance(commands, list) assert "get" in commands - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_deluser(self, r, request): @@ -111,7 +108,6 @@ def teardown(): assert r.acl_getuser(users[3]) is None assert r.acl_getuser(users[4]) is None - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_genpass(self, r): @@ -126,7 +122,6 @@ def test_acl_genpass(self, r): r.acl_genpass(555) assert isinstance(password, str) - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_getuser_setuser(self, r, request): @@ -234,14 +229,12 @@ def teardown(): ) assert len(r.acl_getuser(username)["passwords"]) == 1 - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_help(self, r): res = r.acl_help() assert isinstance(res, list) assert len(res) != 0 - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_list(self, r, request): @@ -256,7 +249,6 @@ def teardown(): users = r.acl_list() assert len(users) == 2 - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_log(self, r, request): @@ -299,7 +291,6 @@ def teardown(): assert "client-info" in r.acl_log(count=1)[0] assert r.acl_log_reset() - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_categories_without_prefix_fails(self, r, request): @@ -313,7 +304,6 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, categories=["list"]) - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_commands_without_prefix_fails(self, r, request): @@ -327,7 +317,6 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, commands=["get"]) - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") @skip_if_redis_enterprise def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request): @@ -341,14 +330,12 @@ def teardown(): with pytest.raises(exceptions.DataError): r.acl_setuser(username, passwords="+mypass", nopass=True) - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_users(self, r): users = r.acl_users() assert isinstance(users, list) assert len(users) > 0 - @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") def test_acl_whoami(self, r): username = r.acl_whoami() @@ -549,7 +536,7 @@ def test_client_kill_filter_by_user(self, r, request): killuser, enabled=True, reset=True, - commands=["+get", "+set", "+select"], + commands=["+get", "+set", "+select", "+cluster", "+command"], keys=["cache:*"], nopass=True, ) From 42101fc383829bb179a266420132d3f862861972 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Wed, 1 Dec 2021 19:03:18 +0100 Subject: [PATCH 0283/1164] Integrate RedisBloom support (#1683) Co-authored-by: Chayim I. Kirshen --- redis/commands/bf/__init__.py | 204 ++++++++++++++ redis/commands/bf/commands.py | 494 +++++++++++++++++++++++++++++++++ redis/commands/bf/info.py | 85 ++++++ redis/commands/redismodules.py | 40 +++ setup.py | 1 + tests/test_bloom.py | 383 +++++++++++++++++++++++++ tox.ini | 1 + 7 files changed, 1208 insertions(+) create mode 100644 redis/commands/bf/__init__.py create mode 100644 redis/commands/bf/commands.py create mode 100644 redis/commands/bf/info.py create mode 100644 tests/test_bloom.py diff --git a/redis/commands/bf/__init__.py b/redis/commands/bf/__init__.py new file mode 100644 index 0000000000..f34e11d9bb --- /dev/null +++ b/redis/commands/bf/__init__.py @@ -0,0 +1,204 @@ +from redis.client import bool_ok + +from ..helpers import parse_to_list +from .commands import * # noqa +from .info import BFInfo, CFInfo, CMSInfo, TDigestInfo, TopKInfo + + +class AbstractBloom(object): + """ + The client allows to interact with RedisBloom and use all of + it's functionality. + + - BF for Bloom Filter + - CF for Cuckoo Filter + - CMS for Count-Min Sketch + - TOPK for TopK Data Structure + - TDIGEST for estimate rank statistics + """ + + @staticmethod + def appendItems(params, items): + """Append ITEMS to params.""" + params.extend(["ITEMS"]) + params += items + + @staticmethod + def appendError(params, error): + """Append ERROR to params.""" + if error is not None: + params.extend(["ERROR", error]) + + @staticmethod + def appendCapacity(params, capacity): + """Append CAPACITY to params.""" + if capacity is not None: + params.extend(["CAPACITY", capacity]) + + @staticmethod + def appendExpansion(params, expansion): + """Append EXPANSION to params.""" + if expansion is not None: + params.extend(["EXPANSION", expansion]) + + @staticmethod + def appendNoScale(params, noScale): + """Append NONSCALING tag to params.""" + if noScale is not None: + params.extend(["NONSCALING"]) + + @staticmethod + def appendWeights(params, weights): + """Append WEIGHTS to params.""" + if len(weights) > 0: + params.append("WEIGHTS") + params += weights + + @staticmethod + def appendNoCreate(params, noCreate): + """Append NOCREATE tag to params.""" + if noCreate is not None: + params.extend(["NOCREATE"]) + + @staticmethod + def appendItemsAndIncrements(params, items, increments): + """Append pairs of items and increments to params.""" + for i in range(len(items)): + params.append(items[i]) + params.append(increments[i]) + + @staticmethod + def appendValuesAndWeights(params, items, weights): + """Append pairs of items and weights to params.""" + for i in range(len(items)): + params.append(items[i]) + params.append(weights[i]) + + @staticmethod + def appendMaxIterations(params, max_iterations): + """Append MAXITERATIONS to params.""" + if max_iterations is not None: + params.extend(["MAXITERATIONS", max_iterations]) + + @staticmethod + def appendBucketSize(params, bucket_size): + """Append BUCKETSIZE to params.""" + if bucket_size is not None: + params.extend(["BUCKETSIZE", bucket_size]) + + +class CMSBloom(CMSCommands, AbstractBloom): + def __init__(self, client, **kwargs): + """Create a new RedisBloom client.""" + # Set the module commands' callbacks + MODULE_CALLBACKS = { + CMS_INITBYDIM: bool_ok, + CMS_INITBYPROB: bool_ok, + # CMS_INCRBY: spaceHolder, + # CMS_QUERY: spaceHolder, + CMS_MERGE: bool_ok, + CMS_INFO: CMSInfo, + } + + self.client = client + self.commandmixin = CMSCommands + self.execute_command = client.execute_command + + for k, v in MODULE_CALLBACKS.items(): + self.client.set_response_callback(k, v) + + +class TOPKBloom(TOPKCommands, AbstractBloom): + def __init__(self, client, **kwargs): + """Create a new RedisBloom client.""" + # Set the module commands' callbacks + MODULE_CALLBACKS = { + TOPK_RESERVE: bool_ok, + TOPK_ADD: parse_to_list, + TOPK_INCRBY: parse_to_list, + # TOPK_QUERY: spaceHolder, + # TOPK_COUNT: spaceHolder, + TOPK_LIST: parse_to_list, + TOPK_INFO: TopKInfo, + } + + self.client = client + self.commandmixin = TOPKCommands + self.execute_command = client.execute_command + + for k, v in MODULE_CALLBACKS.items(): + self.client.set_response_callback(k, v) + + +class CFBloom(CFCommands, AbstractBloom): + def __init__(self, client, **kwargs): + """Create a new RedisBloom client.""" + # Set the module commands' callbacks + MODULE_CALLBACKS = { + CF_RESERVE: bool_ok, + # CF_ADD: spaceHolder, + # CF_ADDNX: spaceHolder, + # CF_INSERT: spaceHolder, + # CF_INSERTNX: spaceHolder, + # CF_EXISTS: spaceHolder, + # CF_DEL: spaceHolder, + # CF_COUNT: spaceHolder, + # CF_SCANDUMP: spaceHolder, + # CF_LOADCHUNK: spaceHolder, + CF_INFO: CFInfo, + } + + self.client = client + self.commandmixin = CFCommands + self.execute_command = client.execute_command + + for k, v in MODULE_CALLBACKS.items(): + self.client.set_response_callback(k, v) + + +class TDigestBloom(TDigestCommands, AbstractBloom): + def __init__(self, client, **kwargs): + """Create a new RedisBloom client.""" + # Set the module commands' callbacks + MODULE_CALLBACKS = { + TDIGEST_CREATE: bool_ok, + # TDIGEST_RESET: bool_ok, + # TDIGEST_ADD: spaceHolder, + # TDIGEST_MERGE: spaceHolder, + TDIGEST_CDF: float, + TDIGEST_QUANTILE: float, + TDIGEST_MIN: float, + TDIGEST_MAX: float, + TDIGEST_INFO: TDigestInfo, + } + + self.client = client + self.commandmixin = TDigestCommands + self.execute_command = client.execute_command + + for k, v in MODULE_CALLBACKS.items(): + self.client.set_response_callback(k, v) + + +class BFBloom(BFCommands, AbstractBloom): + def __init__(self, client, **kwargs): + """Create a new RedisBloom client.""" + # Set the module commands' callbacks + MODULE_CALLBACKS = { + BF_RESERVE: bool_ok, + # BF_ADD: spaceHolder, + # BF_MADD: spaceHolder, + # BF_INSERT: spaceHolder, + # BF_EXISTS: spaceHolder, + # BF_MEXISTS: spaceHolder, + # BF_SCANDUMP: spaceHolder, + # BF_LOADCHUNK: spaceHolder, + BF_INFO: BFInfo, + } + + self.client = client + self.commandmixin = BFCommands + self.execute_command = client.execute_command + + for k, v in MODULE_CALLBACKS.items(): + self.client.set_response_callback(k, v) diff --git a/redis/commands/bf/commands.py b/redis/commands/bf/commands.py new file mode 100644 index 0000000000..3c8bf7f31e --- /dev/null +++ b/redis/commands/bf/commands.py @@ -0,0 +1,494 @@ +from redis.client import NEVER_DECODE +from redis.exceptions import ModuleError +from redis.utils import HIREDIS_AVAILABLE + +BF_RESERVE = "BF.RESERVE" +BF_ADD = "BF.ADD" +BF_MADD = "BF.MADD" +BF_INSERT = "BF.INSERT" +BF_EXISTS = "BF.EXISTS" +BF_MEXISTS = "BF.MEXISTS" +BF_SCANDUMP = "BF.SCANDUMP" +BF_LOADCHUNK = "BF.LOADCHUNK" +BF_INFO = "BF.INFO" + +CF_RESERVE = "CF.RESERVE" +CF_ADD = "CF.ADD" +CF_ADDNX = "CF.ADDNX" +CF_INSERT = "CF.INSERT" +CF_INSERTNX = "CF.INSERTNX" +CF_EXISTS = "CF.EXISTS" +CF_DEL = "CF.DEL" +CF_COUNT = "CF.COUNT" +CF_SCANDUMP = "CF.SCANDUMP" +CF_LOADCHUNK = "CF.LOADCHUNK" +CF_INFO = "CF.INFO" + +CMS_INITBYDIM = "CMS.INITBYDIM" +CMS_INITBYPROB = "CMS.INITBYPROB" +CMS_INCRBY = "CMS.INCRBY" +CMS_QUERY = "CMS.QUERY" +CMS_MERGE = "CMS.MERGE" +CMS_INFO = "CMS.INFO" + +TOPK_RESERVE = "TOPK.RESERVE" +TOPK_ADD = "TOPK.ADD" +TOPK_INCRBY = "TOPK.INCRBY" +TOPK_QUERY = "TOPK.QUERY" +TOPK_COUNT = "TOPK.COUNT" +TOPK_LIST = "TOPK.LIST" +TOPK_INFO = "TOPK.INFO" + +TDIGEST_CREATE = "TDIGEST.CREATE" +TDIGEST_RESET = "TDIGEST.RESET" +TDIGEST_ADD = "TDIGEST.ADD" +TDIGEST_MERGE = "TDIGEST.MERGE" +TDIGEST_CDF = "TDIGEST.CDF" +TDIGEST_QUANTILE = "TDIGEST.QUANTILE" +TDIGEST_MIN = "TDIGEST.MIN" +TDIGEST_MAX = "TDIGEST.MAX" +TDIGEST_INFO = "TDIGEST.INFO" + + +class BFCommands: + """RedisBloom commands.""" + + # region Bloom Filter Functions + def create(self, key, errorRate, capacity, expansion=None, noScale=None): + """ + Create a new Bloom Filter `key` with desired probability of false positives + `errorRate` expected entries to be inserted as `capacity`. + Default expansion value is 2. By default, filter is auto-scaling. + For more information see `BF.RESERVE `_. + """ # noqa + params = [key, errorRate, capacity] + self.appendExpansion(params, expansion) + self.appendNoScale(params, noScale) + return self.execute_command(BF_RESERVE, *params) + + def add(self, key, item): + """ + Add to a Bloom Filter `key` an `item`. + For more information see `BF.ADD `_. + """ # noqa + params = [key, item] + return self.execute_command(BF_ADD, *params) + + def madd(self, key, *items): + """ + Add to a Bloom Filter `key` multiple `items`. + For more information see `BF.MADD `_. + """ # noqa + params = [key] + params += items + return self.execute_command(BF_MADD, *params) + + def insert( + self, + key, + items, + capacity=None, + error=None, + noCreate=None, + expansion=None, + noScale=None, + ): + """ + Add to a Bloom Filter `key` multiple `items`. + + If `nocreate` remain `None` and `key` does not exist, a new Bloom Filter + `key` will be created with desired probability of false positives `errorRate` + and expected entries to be inserted as `size`. + For more information see `BF.INSERT `_. + """ # noqa + params = [key] + self.appendCapacity(params, capacity) + self.appendError(params, error) + self.appendExpansion(params, expansion) + self.appendNoCreate(params, noCreate) + self.appendNoScale(params, noScale) + self.appendItems(params, items) + + return self.execute_command(BF_INSERT, *params) + + def exists(self, key, item): + """ + Check whether an `item` exists in Bloom Filter `key`. + For more information see `BF.EXISTS `_. + """ # noqa + params = [key, item] + return self.execute_command(BF_EXISTS, *params) + + def mexists(self, key, *items): + """ + Check whether `items` exist in Bloom Filter `key`. + For more information see `BF.MEXISTS `_. + """ # noqa + params = [key] + params += items + return self.execute_command(BF_MEXISTS, *params) + + def scandump(self, key, iter): + """ + Begin an incremental save of the bloom filter `key`. + + This is useful for large bloom filters which cannot fit into the normal SAVE and RESTORE model. + The first time this command is called, the value of `iter` should be 0. + This command will return successive (iter, data) pairs until (0, NULL) to indicate completion. + For more information see `BF.SCANDUMP `_. + """ # noqa + if HIREDIS_AVAILABLE: + raise ModuleError("This command cannot be used when hiredis is available.") + + params = [key, iter] + options = {} + options[NEVER_DECODE] = [] + return self.execute_command(BF_SCANDUMP, *params, **options) + + def loadchunk(self, key, iter, data): + """ + Restore a filter previously saved using SCANDUMP. + + See the SCANDUMP command for example usage. + This command will overwrite any bloom filter stored under key. + Ensure that the bloom filter will not be modified between invocations. + For more information see `BF.LOADCHUNK `_. + """ # noqa + params = [key, iter, data] + return self.execute_command(BF_LOADCHUNK, *params) + + def info(self, key): + """ + Return capacity, size, number of filters, number of items inserted, and expansion rate. + For more information see `BF.INFO `_. + """ # noqa + return self.execute_command(BF_INFO, key) + + +class CFCommands: + + # region Cuckoo Filter Functions + def create( + self, key, capacity, expansion=None, bucket_size=None, max_iterations=None + ): + """ + Create a new Cuckoo Filter `key` an initial `capacity` items. + For more information see `CF.RESERVE `_. + """ # noqa + params = [key, capacity] + self.appendExpansion(params, expansion) + self.appendBucketSize(params, bucket_size) + self.appendMaxIterations(params, max_iterations) + return self.execute_command(CF_RESERVE, *params) + + def add(self, key, item): + """ + Add an `item` to a Cuckoo Filter `key`. + For more information see `CF.ADD `_. + """ # noqa + params = [key, item] + return self.execute_command(CF_ADD, *params) + + def addnx(self, key, item): + """ + Add an `item` to a Cuckoo Filter `key` only if item does not yet exist. + Command might be slower that `add`. + For more information see `CF.ADDNX `_. + """ # noqa + params = [key, item] + return self.execute_command(CF_ADDNX, *params) + + def insert(self, key, items, capacity=None, nocreate=None): + """ + Add multiple `items` to a Cuckoo Filter `key`, allowing the filter + to be created with a custom `capacity` if it does not yet exist. + `items` must be provided as a list. + For more information see `CF.INSERT `_. + """ # noqa + params = [key] + self.appendCapacity(params, capacity) + self.appendNoCreate(params, nocreate) + self.appendItems(params, items) + return self.execute_command(CF_INSERT, *params) + + def insertnx(self, key, items, capacity=None, nocreate=None): + """ + Add multiple `items` to a Cuckoo Filter `key` only if they do not exist yet, + allowing the filter to be created with a custom `capacity` if it does not yet exist. + `items` must be provided as a list. + For more information see `CF.INSERTNX `_. + """ # noqa + params = [key] + self.appendCapacity(params, capacity) + self.appendNoCreate(params, nocreate) + self.appendItems(params, items) + return self.execute_command(CF_INSERTNX, *params) + + def exists(self, key, item): + """ + Check whether an `item` exists in Cuckoo Filter `key`. + For more information see `CF.EXISTS `_. + """ # noqa + params = [key, item] + return self.execute_command(CF_EXISTS, *params) + + def delete(self, key, item): + """ + Delete `item` from `key`. + For more information see `CF.DEL `_. + """ # noqa + params = [key, item] + return self.execute_command(CF_DEL, *params) + + def count(self, key, item): + """ + Return the number of times an `item` may be in the `key`. + For more information see `CF.COUNT `_. + """ # noqa + params = [key, item] + return self.execute_command(CF_COUNT, *params) + + def scandump(self, key, iter): + """ + Begin an incremental save of the Cuckoo filter `key`. + + This is useful for large Cuckoo filters which cannot fit into the normal + SAVE and RESTORE model. + The first time this command is called, the value of `iter` should be 0. + This command will return successive (iter, data) pairs until + (0, NULL) to indicate completion. + For more information see `CF.SCANDUMP `_. + """ # noqa + params = [key, iter] + return self.execute_command(CF_SCANDUMP, *params) + + def loadchunk(self, key, iter, data): + """ + Restore a filter previously saved using SCANDUMP. See the SCANDUMP command for example usage. + + This command will overwrite any Cuckoo filter stored under key. + Ensure that the Cuckoo filter will not be modified between invocations. + For more information see `CF.LOADCHUNK `_. + """ # noqa + params = [key, iter, data] + return self.execute_command(CF_LOADCHUNK, *params) + + def info(self, key): + """ + Return size, number of buckets, number of filter, number of items inserted, + number of items deleted, bucket size, expansion rate, and max iteration. + For more information see `CF.INFO `_. + """ # noqa + return self.execute_command(CF_INFO, key) + + +class TOPKCommands: + def reserve(self, key, k, width, depth, decay): + """ + Create a new Top-K Filter `key` with desired probability of false + positives `errorRate` expected entries to be inserted as `size`. + For more information see `TOPK.RESERVE `_. + """ # noqa + params = [key, k, width, depth, decay] + return self.execute_command(TOPK_RESERVE, *params) + + def add(self, key, *items): + """ + Add one `item` or more to a Top-K Filter `key`. + For more information see `TOPK.ADD `_. + """ # noqa + params = [key] + params += items + return self.execute_command(TOPK_ADD, *params) + + def incrby(self, key, items, increments): + """ + Add/increase `items` to a Top-K Sketch `key` by ''increments''. + Both `items` and `increments` are lists. + For more information see `TOPK.INCRBY `_. + + Example: + + >>> topkincrby('A', ['foo'], [1]) + """ # noqa + params = [key] + self.appendItemsAndIncrements(params, items, increments) + return self.execute_command(TOPK_INCRBY, *params) + + def query(self, key, *items): + """ + Check whether one `item` or more is a Top-K item at `key`. + For more information see `TOPK.QUERY `_. + """ # noqa + params = [key] + params += items + return self.execute_command(TOPK_QUERY, *params) + + def count(self, key, *items): + """ + Return count for one `item` or more from `key`. + For more information see `TOPK.COUNT `_. + """ # noqa + params = [key] + params += items + return self.execute_command(TOPK_COUNT, *params) + + def list(self, key, withcount=False): + """ + Return full list of items in Top-K list of `key`. + If `withcount` set to True, return full list of items + with probabilistic count in Top-K list of `key`. + For more information see `TOPK.LIST `_. + """ # noqa + params = [key] + if withcount: + params.append("WITHCOUNT") + return self.execute_command(TOPK_LIST, *params) + + def info(self, key): + """ + Return k, width, depth and decay values of `key`. + For more information see `TOPK.INFO `_. + """ # noqa + return self.execute_command(TOPK_INFO, key) + + +class TDigestCommands: + def create(self, key, compression): + """ + Allocate the memory and initialize the t-digest. + For more information see `TDIGEST.CREATE `_. + """ # noqa + params = [key, compression] + return self.execute_command(TDIGEST_CREATE, *params) + + def reset(self, key): + """ + Reset the sketch `key` to zero - empty out the sketch and re-initialize it. + For more information see `TDIGEST.RESET `_. + """ # noqa + return self.execute_command(TDIGEST_RESET, key) + + def add(self, key, values, weights): + """ + Add one or more samples (value with weight) to a sketch `key`. + Both `values` and `weights` are lists. + For more information see `TDIGEST.ADD `_. + + Example: + + >>> tdigestadd('A', [1500.0], [1.0]) + """ # noqa + params = [key] + self.appendValuesAndWeights(params, values, weights) + return self.execute_command(TDIGEST_ADD, *params) + + def merge(self, toKey, fromKey): + """ + Merge all of the values from 'fromKey' to 'toKey' sketch. + For more information see `TDIGEST.MERGE `_. + """ # noqa + params = [toKey, fromKey] + return self.execute_command(TDIGEST_MERGE, *params) + + def min(self, key): + """ + Return minimum value from the sketch `key`. Will return DBL_MAX if the sketch is empty. + For more information see `TDIGEST.MIN `_. + """ # noqa + return self.execute_command(TDIGEST_MIN, key) + + def max(self, key): + """ + Return maximum value from the sketch `key`. Will return DBL_MIN if the sketch is empty. + For more information see `TDIGEST.MAX `_. + """ # noqa + return self.execute_command(TDIGEST_MAX, key) + + def quantile(self, key, quantile): + """ + Return double value estimate of the cutoff such that a specified fraction of the data + added to this TDigest would be less than or equal to the cutoff. + For more information see `TDIGEST.QUANTILE `_. + """ # noqa + params = [key, quantile] + return self.execute_command(TDIGEST_QUANTILE, *params) + + def cdf(self, key, value): + """ + Return double fraction of all points added which are <= value. + For more information see `TDIGEST.CDF `_. + """ # noqa + params = [key, value] + return self.execute_command(TDIGEST_CDF, *params) + + def info(self, key): + """ + Return Compression, Capacity, Merged Nodes, Unmerged Nodes, Merged Weight, Unmerged Weight + and Total Compressions. + For more information see `TDIGEST.INFO `_. + """ # noqa + return self.execute_command(TDIGEST_INFO, key) + + +class CMSCommands: + + # region Count-Min Sketch Functions + def initbydim(self, key, width, depth): + """ + Initialize a Count-Min Sketch `key` to dimensions (`width`, `depth`) specified by user. + For more information see `CMS.INITBYDIM `_. + """ # noqa + params = [key, width, depth] + return self.execute_command(CMS_INITBYDIM, *params) + + def initbyprob(self, key, error, probability): + """ + Initialize a Count-Min Sketch `key` to characteristics (`error`, `probability`) specified by user. + For more information see `CMS.INITBYPROB `_. + """ # noqa + params = [key, error, probability] + return self.execute_command(CMS_INITBYPROB, *params) + + def incrby(self, key, items, increments): + """ + Add/increase `items` to a Count-Min Sketch `key` by ''increments''. + Both `items` and `increments` are lists. + For more information see `CMS.INCRBY `_. + + Example: + + >>> cmsincrby('A', ['foo'], [1]) + """ # noqa + params = [key] + self.appendItemsAndIncrements(params, items, increments) + return self.execute_command(CMS_INCRBY, *params) + + def query(self, key, *items): + """ + Return count for an `item` from `key`. Multiple items can be queried with one call. + For more information see `CMS.QUERY `_. + """ # noqa + params = [key] + params += items + return self.execute_command(CMS_QUERY, *params) + + def merge(self, destKey, numKeys, srcKeys, weights=[]): + """ + Merge `numKeys` of sketches into `destKey`. Sketches specified in `srcKeys`. + All sketches must have identical width and depth. + `Weights` can be used to multiply certain sketches. Default weight is 1. + Both `srcKeys` and `weights` are lists. + For more information see `CMS.MERGE `_. + """ # noqa + params = [destKey, numKeys] + params += srcKeys + self.appendWeights(params, weights) + return self.execute_command(CMS_MERGE, *params) + + def info(self, key): + """ + Return width, depth and total count of the sketch. + For more information see `CMS.INFO `_. + """ # noqa + return self.execute_command(CMS_INFO, key) diff --git a/redis/commands/bf/info.py b/redis/commands/bf/info.py new file mode 100644 index 0000000000..24c5419bb7 --- /dev/null +++ b/redis/commands/bf/info.py @@ -0,0 +1,85 @@ +from ..helpers import nativestr + + +class BFInfo(object): + capacity = None + size = None + filterNum = None + insertedNum = None + expansionRate = None + + def __init__(self, args): + response = dict(zip(map(nativestr, args[::2]), args[1::2])) + self.capacity = response["Capacity"] + self.size = response["Size"] + self.filterNum = response["Number of filters"] + self.insertedNum = response["Number of items inserted"] + self.expansionRate = response["Expansion rate"] + + +class CFInfo(object): + size = None + bucketNum = None + filterNum = None + insertedNum = None + deletedNum = None + bucketSize = None + expansionRate = None + maxIteration = None + + def __init__(self, args): + response = dict(zip(map(nativestr, args[::2]), args[1::2])) + self.size = response["Size"] + self.bucketNum = response["Number of buckets"] + self.filterNum = response["Number of filters"] + self.insertedNum = response["Number of items inserted"] + self.deletedNum = response["Number of items deleted"] + self.bucketSize = response["Bucket size"] + self.expansionRate = response["Expansion rate"] + self.maxIteration = response["Max iterations"] + + +class CMSInfo(object): + width = None + depth = None + count = None + + def __init__(self, args): + response = dict(zip(map(nativestr, args[::2]), args[1::2])) + self.width = response["width"] + self.depth = response["depth"] + self.count = response["count"] + + +class TopKInfo(object): + k = None + width = None + depth = None + decay = None + + def __init__(self, args): + response = dict(zip(map(nativestr, args[::2]), args[1::2])) + self.k = response["k"] + self.width = response["width"] + self.depth = response["depth"] + self.decay = response["decay"] + + +class TDigestInfo(object): + compression = None + capacity = None + mergedNodes = None + unmergedNodes = None + mergedWeight = None + unmergedWeight = None + totalCompressions = None + + def __init__(self, args): + response = dict(zip(map(nativestr, args[::2]), args[1::2])) + self.compression = response["Compression"] + self.capacity = response["Capacity"] + self.mergedNodes = response["Merged nodes"] + self.unmergedNodes = response["Unmerged nodes"] + self.mergedWeight = response["Merged weight"] + self.unmergedWeight = response["Unmerged weight"] + self.totalCompressions = response["Total compressions"] diff --git a/redis/commands/redismodules.py b/redis/commands/redismodules.py index e5ace63113..eafd6500ea 100644 --- a/redis/commands/redismodules.py +++ b/redis/commands/redismodules.py @@ -32,6 +32,46 @@ def ts(self): s = TimeSeries(client=self) return s + def bf(self): + """Access the bloom namespace.""" + + from .bf import BFBloom + + bf = BFBloom(client=self) + return bf + + def cf(self): + """Access the bloom namespace.""" + + from .bf import CFBloom + + cf = CFBloom(client=self) + return cf + + def cms(self): + """Access the bloom namespace.""" + + from .bf import CMSBloom + + cms = CMSBloom(client=self) + return cms + + def topk(self): + """Access the bloom namespace.""" + + from .bf import TOPKBloom + + topk = TOPKBloom(client=self) + return topk + + def tdigest(self): + """Access the bloom namespace.""" + + from .bf import TDigestBloom + + tdigest = TDigestBloom(client=self) + return tdigest + def graph(self, index_name="idx"): """Access the timeseries namespace, providing support for redis timeseries data. diff --git a/setup.py b/setup.py index d8308010c9..58d753fb3a 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ include=[ "redis", "redis.commands", + "redis.commands.bf", "redis.commands.json", "redis.commands.search", "redis.commands.timeseries", diff --git a/tests/test_bloom.py b/tests/test_bloom.py new file mode 100644 index 0000000000..8936584ea8 --- /dev/null +++ b/tests/test_bloom.py @@ -0,0 +1,383 @@ +import pytest + +import redis.commands.bf +from redis.exceptions import ModuleError, RedisError +from redis.utils import HIREDIS_AVAILABLE + + +def intlist(obj): + return [int(v) for v in obj] + + +@pytest.fixture +def client(modclient): + assert isinstance(modclient.bf(), redis.commands.bf.BFBloom) + assert isinstance(modclient.cf(), redis.commands.bf.CFBloom) + assert isinstance(modclient.cms(), redis.commands.bf.CMSBloom) + assert isinstance(modclient.tdigest(), redis.commands.bf.TDigestBloom) + assert isinstance(modclient.topk(), redis.commands.bf.TOPKBloom) + + modclient.flushdb() + return modclient + + +@pytest.mark.redismod +def test_create(client): + """Test CREATE/RESERVE calls""" + assert client.bf().create("bloom", 0.01, 1000) + assert client.bf().create("bloom_e", 0.01, 1000, expansion=1) + assert client.bf().create("bloom_ns", 0.01, 1000, noScale=True) + assert client.cf().create("cuckoo", 1000) + assert client.cf().create("cuckoo_e", 1000, expansion=1) + assert client.cf().create("cuckoo_bs", 1000, bucket_size=4) + assert client.cf().create("cuckoo_mi", 1000, max_iterations=10) + assert client.cms().initbydim("cmsDim", 100, 5) + assert client.cms().initbyprob("cmsProb", 0.01, 0.01) + assert client.topk().reserve("topk", 5, 100, 5, 0.9) + assert client.tdigest().create("tDigest", 100) + + +# region Test Bloom Filter +@pytest.mark.redismod +def test_bf_add(client): + assert client.bf().create("bloom", 0.01, 1000) + assert 1 == client.bf().add("bloom", "foo") + assert 0 == client.bf().add("bloom", "foo") + assert [0] == intlist(client.bf().madd("bloom", "foo")) + assert [0, 1] == client.bf().madd("bloom", "foo", "bar") + assert [0, 0, 1] == client.bf().madd("bloom", "foo", "bar", "baz") + assert 1 == client.bf().exists("bloom", "foo") + assert 0 == client.bf().exists("bloom", "noexist") + assert [1, 0] == intlist(client.bf().mexists("bloom", "foo", "noexist")) + + +@pytest.mark.redismod +def test_bf_insert(client): + assert client.bf().create("bloom", 0.01, 1000) + assert [1] == intlist(client.bf().insert("bloom", ["foo"])) + assert [0, 1] == intlist(client.bf().insert("bloom", ["foo", "bar"])) + assert [1] == intlist(client.bf().insert("captest", ["foo"], capacity=10)) + assert [1] == intlist(client.bf().insert("errtest", ["foo"], error=0.01)) + assert 1 == client.bf().exists("bloom", "foo") + assert 0 == client.bf().exists("bloom", "noexist") + assert [1, 0] == intlist(client.bf().mexists("bloom", "foo", "noexist")) + info = client.bf().info("bloom") + assert 2 == info.insertedNum + assert 1000 == info.capacity + assert 1 == info.filterNum + + +@pytest.mark.redismod +def test_bf_scandump_and_loadchunk(client): + # Store a filter + client.bf().create("myBloom", "0.0001", "1000") + + # test is probabilistic and might fail. It is OK to change variables if + # certain to not break anything + def do_verify(): + res = 0 + for x in range(1000): + client.bf().add("myBloom", x) + rv = client.bf().exists("myBloom", x) + assert rv + rv = client.bf().exists("myBloom", f"nonexist_{x}") + res += rv == x + assert res < 5 + + do_verify() + cmds = [] + if HIREDIS_AVAILABLE: + with pytest.raises(ModuleError): + cur = client.bf().scandump("myBloom", 0) + return + + cur = client.bf().scandump("myBloom", 0) + first = cur[0] + cmds.append(cur) + + while True: + cur = client.bf().scandump("myBloom", first) + first = cur[0] + if first == 0: + break + else: + cmds.append(cur) + prev_info = client.bf().execute_command("bf.debug", "myBloom") + + # Remove the filter + client.bf().client.delete("myBloom") + + # Now, load all the commands: + for cmd in cmds: + client.bf().loadchunk("myBloom", *cmd) + + cur_info = client.bf().execute_command("bf.debug", "myBloom") + assert prev_info == cur_info + do_verify() + + client.bf().client.delete("myBloom") + client.bf().create("myBloom", "0.0001", "10000000") + + +@pytest.mark.redismod +def test_bf_info(client): + expansion = 4 + # Store a filter + client.bf().create("nonscaling", "0.0001", "1000", noScale=True) + info = client.bf().info("nonscaling") + assert info.expansionRate is None + + client.bf().create("expanding", "0.0001", "1000", expansion=expansion) + info = client.bf().info("expanding") + assert info.expansionRate == 4 + + try: + # noScale mean no expansion + client.bf().create( + "myBloom", "0.0001", "1000", expansion=expansion, noScale=True + ) + assert False + except RedisError: + assert True + + +# region Test Cuckoo Filter +@pytest.mark.redismod +def test_cf_add_and_insert(client): + assert client.cf().create("cuckoo", 1000) + assert client.cf().add("cuckoo", "filter") + assert not client.cf().addnx("cuckoo", "filter") + assert 1 == client.cf().addnx("cuckoo", "newItem") + assert [1] == client.cf().insert("captest", ["foo"]) + assert [1] == client.cf().insert("captest", ["foo"], capacity=1000) + assert [1] == client.cf().insertnx("captest", ["bar"]) + assert [1] == client.cf().insertnx("captest", ["food"], nocreate="1") + assert [0, 0, 1] == client.cf().insertnx("captest", ["foo", "bar", "baz"]) + assert [0] == client.cf().insertnx("captest", ["bar"], capacity=1000) + assert [1] == client.cf().insert("empty1", ["foo"], capacity=1000) + assert [1] == client.cf().insertnx("empty2", ["bar"], capacity=1000) + info = client.cf().info("captest") + assert 5 == info.insertedNum + assert 0 == info.deletedNum + assert 1 == info.filterNum + + +@pytest.mark.redismod +def test_cf_exists_and_del(client): + assert client.cf().create("cuckoo", 1000) + assert client.cf().add("cuckoo", "filter") + assert client.cf().exists("cuckoo", "filter") + assert not client.cf().exists("cuckoo", "notexist") + assert 1 == client.cf().count("cuckoo", "filter") + assert 0 == client.cf().count("cuckoo", "notexist") + assert client.cf().delete("cuckoo", "filter") + assert 0 == client.cf().count("cuckoo", "filter") + + +# region Test Count-Min Sketch +@pytest.mark.redismod +def test_cms(client): + assert client.cms().initbydim("dim", 1000, 5) + assert client.cms().initbyprob("prob", 0.01, 0.01) + assert client.cms().incrby("dim", ["foo"], [5]) + assert [0] == client.cms().query("dim", "notexist") + assert [5] == client.cms().query("dim", "foo") + assert [10, 15] == client.cms().incrby("dim", ["foo", "bar"], [5, 15]) + assert [10, 15] == client.cms().query("dim", "foo", "bar") + info = client.cms().info("dim") + assert 1000 == info.width + assert 5 == info.depth + assert 25 == info.count + + +@pytest.mark.redismod +def test_cms_merge(client): + assert client.cms().initbydim("A", 1000, 5) + assert client.cms().initbydim("B", 1000, 5) + assert client.cms().initbydim("C", 1000, 5) + assert client.cms().incrby("A", ["foo", "bar", "baz"], [5, 3, 9]) + assert client.cms().incrby("B", ["foo", "bar", "baz"], [2, 3, 1]) + assert [5, 3, 9] == client.cms().query("A", "foo", "bar", "baz") + assert [2, 3, 1] == client.cms().query("B", "foo", "bar", "baz") + assert client.cms().merge("C", 2, ["A", "B"]) + assert [7, 6, 10] == client.cms().query("C", "foo", "bar", "baz") + assert client.cms().merge("C", 2, ["A", "B"], ["1", "2"]) + assert [9, 9, 11] == client.cms().query("C", "foo", "bar", "baz") + assert client.cms().merge("C", 2, ["A", "B"], ["2", "3"]) + assert [16, 15, 21] == client.cms().query("C", "foo", "bar", "baz") + + +# endregion + + +# region Test Top-K +@pytest.mark.redismod +def test_topk(client): + # test list with empty buckets + assert client.topk().reserve("topk", 3, 50, 4, 0.9) + assert [ + None, + None, + None, + "A", + "C", + "D", + None, + None, + "E", + None, + "B", + "C", + None, + None, + None, + "D", + None, + ] == client.topk().add( + "topk", + "A", + "B", + "C", + "D", + "E", + "A", + "A", + "B", + "C", + "G", + "D", + "B", + "D", + "A", + "E", + "E", + 1, + ) + assert [1, 1, 0, 0, 1, 0, 0] == client.topk().query( + "topk", "A", "B", "C", "D", "E", "F", "G" + ) + assert [4, 3, 2, 3, 3, 0, 1] == client.topk().count( + "topk", "A", "B", "C", "D", "E", "F", "G" + ) + + # test full list + assert client.topk().reserve("topklist", 3, 50, 3, 0.9) + assert client.topk().add( + "topklist", + "A", + "B", + "C", + "D", + "E", + "A", + "A", + "B", + "C", + "G", + "D", + "B", + "D", + "A", + "E", + "E", + ) + assert ["A", "B", "E"] == client.topk().list("topklist") + assert ["A", 4, "B", 3, "E", 3] == client.topk().list("topklist", withcount=True) + info = client.topk().info("topklist") + assert 3 == info.k + assert 50 == info.width + assert 3 == info.depth + assert 0.9 == round(float(info.decay), 1) + + +@pytest.mark.redismod +def test_topk_incrby(client): + client.flushdb() + assert client.topk().reserve("topk", 3, 10, 3, 1) + assert [None, None, None] == client.topk().incrby( + "topk", ["bar", "baz", "42"], [3, 6, 2] + ) + assert [None, "bar"] == client.topk().incrby("topk", ["42", "xyzzy"], [8, 4]) + assert [3, 6, 10, 4, 0] == client.topk().count( + "topk", "bar", "baz", "42", "xyzzy", 4 + ) + + +# region Test T-Digest +@pytest.mark.redismod +def test_tdigest_reset(client): + assert client.tdigest().create("tDigest", 10) + # reset on empty histogram + assert client.tdigest().reset("tDigest") + # insert data-points into sketch + assert client.tdigest().add("tDigest", list(range(10)), [1.0] * 10) + + assert client.tdigest().reset("tDigest") + # assert we have 0 unmerged nodes + assert 0 == client.tdigest().info("tDigest").unmergedNodes + + +@pytest.mark.redismod +def test_tdigest_merge(client): + assert client.tdigest().create("to-tDigest", 10) + assert client.tdigest().create("from-tDigest", 10) + # insert data-points into sketch + assert client.tdigest().add("from-tDigest", [1.0] * 10, [1.0] * 10) + assert client.tdigest().add("to-tDigest", [2.0] * 10, [10.0] * 10) + # merge from-tdigest into to-tdigest + assert client.tdigest().merge("to-tDigest", "from-tDigest") + # we should now have 110 weight on to-histogram + info = client.tdigest().info("to-tDigest") + total_weight_to = float(info.mergedWeight) + float(info.unmergedWeight) + assert 110 == total_weight_to + + +@pytest.mark.redismod +def test_tdigest_min_and_max(client): + assert client.tdigest().create("tDigest", 100) + # insert data-points into sketch + assert client.tdigest().add("tDigest", [1, 2, 3], [1.0] * 3) + # min/max + assert 3 == client.tdigest().max("tDigest") + assert 1 == client.tdigest().min("tDigest") + + +@pytest.mark.redismod +def test_tdigest_quantile(client): + assert client.tdigest().create("tDigest", 500) + # insert data-points into sketch + assert client.tdigest().add( + "tDigest", list([x * 0.01 for x in range(1, 10000)]), [1.0] * 10000 + ) + # assert min min/max have same result as quantile 0 and 1 + assert client.tdigest().max("tDigest") == client.tdigest().quantile("tDigest", 1.0) + assert client.tdigest().min("tDigest") == client.tdigest().quantile("tDigest", 0.0) + + assert 1.0 == round(client.tdigest().quantile("tDigest", 0.01), 2) + assert 99.0 == round(client.tdigest().quantile("tDigest", 0.99), 2) + + +@pytest.mark.redismod +def test_tdigest_cdf(client): + assert client.tdigest().create("tDigest", 100) + # insert data-points into sketch + assert client.tdigest().add("tDigest", list(range(1, 10)), [1.0] * 10) + assert 0.1 == round(client.tdigest().cdf("tDigest", 1.0), 1) + assert 0.9 == round(client.tdigest().cdf("tDigest", 9.0), 1) + + +# @pytest.mark.redismod +# def test_pipeline(client): +# pipeline = client.bf().pipeline() +# assert not client.bf().execute_command("get pipeline") +# +# assert client.bf().create("pipeline", 0.01, 1000) +# for i in range(100): +# pipeline.add("pipeline", i) +# for i in range(100): +# assert not (client.bf().exists("pipeline", i)) +# +# pipeline.execute() +# +# for i in range(100): +# assert client.bf().exists("pipeline", i) diff --git a/tox.ini b/tox.ini index 0ccc9bb537..f4eaedc8c7 100644 --- a/tox.ini +++ b/tox.ini @@ -170,6 +170,7 @@ exclude = venv*, whitelist.py ignore = + F405 W503 E203 E126 From 8f5c1e6b14c4c82d04a3ad141821e2fdabdd0dab Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 2 Dec 2021 14:52:46 +0100 Subject: [PATCH 0284/1164] Aggregation loadall (#1735) Co-authored-by: Chayim --- redis/commands/search/aggregation.py | 14 +++++++++++--- tests/test_search.py | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/redis/commands/search/aggregation.py b/redis/commands/search/aggregation.py index 3db5542ae1..061e69c235 100644 --- a/redis/commands/search/aggregation.py +++ b/redis/commands/search/aggregation.py @@ -103,6 +103,7 @@ def __init__(self, query="*"): self._query = query self._aggregateplan = [] self._loadfields = [] + self._loadall = False self._limit = Limit() self._max = 0 self._with_schema = False @@ -116,9 +117,13 @@ def load(self, *fields): ### Parameters - - **fields**: One or more fields in the format of `@field` + - **fields**: If fields not specified, all the fields will be loaded. + Otherwise, fields should be given in the format of `@field`. """ - self._loadfields.extend(fields) + if fields: + self._loadfields.extend(fields) + else: + self._loadall = True return self def group_by(self, fields, *reducers): @@ -308,7 +313,10 @@ def build_args(self): if self._cursor: ret += self._cursor - if self._loadfields: + if self._loadall: + ret.append("LOAD") + ret.append("*") + elif self._loadfields: ret.append("LOAD") ret.append(str(len(self._loadfields))) ret.extend(self._loadfields) diff --git a/tests/test_search.py b/tests/test_search.py index 5b6a66009a..1a22b665a8 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1054,6 +1054,11 @@ def test_aggregations_load(client): res = client.ft().aggregate(req) assert res.rows[0] == ["t2", "world"] + # load all + req = aggregations.AggregateRequest("*").load() + res = client.ft().aggregate(req) + assert res.rows[0] == ["t1", "hello", "t2", "world"] + @pytest.mark.redismod def test_aggregations_apply(client): From 1a59a7a45feaed2bd0e33ccdbcd92cd305fd7e44 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 2 Dec 2021 15:54:29 +0200 Subject: [PATCH 0285/1164] Run actions in Parallel (#1763) --- .github/workflows/integration.yaml | 6 ++++-- tasks.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 5384996c70..10ab3ed03e 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -34,9 +34,11 @@ jobs: max-parallel: 6 matrix: python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.7'] + test-type: ['standalone', 'cluster'] + connection-type: ['hiredis', 'plain'] env: ACTIONS_ALLOW_UNSECURE_COMMANDS: true - name: Python ${{ matrix.python-version }} tests + name: Python ${{ matrix.python-version }} ${{matrix.test-type}}-${{matrix.connection-type}} tests steps: - uses: actions/checkout@v2 - name: install python @@ -46,7 +48,7 @@ jobs: - name: run tests run: | pip install -r dev_requirements.txt - invoke tests + tox -e ${{matrix.test-type}}-${{matrix.connection-type}} - name: Upload codecov coverage uses: codecov/codecov-action@v2 with: diff --git a/tasks.py b/tasks.py index 9291e7effb..880e70dcf0 100644 --- a/tasks.py +++ b/tasks.py @@ -55,7 +55,7 @@ def standalone_tests(c): """Run all Redis tests against the current python, with and without hiredis.""" print("Starting Redis tests") - run("tox -e standalone-'{hiredis}'") + run("tox -e standalone-'{plain,hiredis}'") @task From d4a9825a72e1b7715d79ce8134e678d9ef537dce Mon Sep 17 00:00:00 2001 From: Maksim Novikov Date: Thu, 2 Dec 2021 15:02:43 +0100 Subject: [PATCH 0286/1164] Allow overriding connection class via keyword arguments (#1752) --- redis/connection.py | 4 ++++ tests/test_connection_pool.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/redis/connection.py b/redis/connection.py index d13fe65ef8..2001c6447b 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -1131,6 +1131,10 @@ class initializer. In the case of conflicting arguments, querystring arguments always win. """ url_options = parse_url(url) + + if "connection_class" in kwargs: + url_options["connection_class"] = kwargs["connection_class"] + kwargs.update(url_options) return cls(**kwargs) diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 2602af82e1..276e77cdb9 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -445,6 +445,15 @@ def test_extra_querystring_options(self): assert pool.connection_class == redis.UnixDomainSocketConnection assert pool.connection_kwargs == {"path": "/socket", "a": "1", "b": "2"} + def test_connection_class_override(self): + class MyConnection(redis.UnixDomainSocketConnection): + pass + + pool = redis.ConnectionPool.from_url( + 'unix:///socket', connection_class=MyConnection + ) + assert pool.connection_class == MyConnection + @pytest.mark.skipif(not ssl_available, reason="SSL not installed") class TestSSLConnectionURLParsing: @@ -455,6 +464,15 @@ def test_host(self): "host": "my.host", } + def test_connection_class_override(self): + class MyConnection(redis.SSLConnection): + pass + + pool = redis.ConnectionPool.from_url( + 'rediss://my.host', connection_class=MyConnection + ) + assert pool.connection_class == MyConnection + def test_cert_reqs_options(self): import ssl From 8af9a3f559452cde86b234ee29cfb5627eb69e5b Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 2 Dec 2021 18:36:03 +0200 Subject: [PATCH 0287/1164] Fixing lint merge issue (#1770) --- tests/test_connection_pool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 276e77cdb9..138fcad4c9 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -450,7 +450,7 @@ class MyConnection(redis.UnixDomainSocketConnection): pass pool = redis.ConnectionPool.from_url( - 'unix:///socket', connection_class=MyConnection + "unix:///socket", connection_class=MyConnection ) assert pool.connection_class == MyConnection @@ -469,7 +469,7 @@ class MyConnection(redis.SSLConnection): pass pool = redis.ConnectionPool.from_url( - 'rediss://my.host', connection_class=MyConnection + "rediss://my.host", connection_class=MyConnection ) assert pool.connection_class == MyConnection From 48b19dfc526102864c7289cf1f3c4889c858d359 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 2 Dec 2021 18:36:45 +0200 Subject: [PATCH 0288/1164] Locking latest tag for pythons docker (#1769) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f4eaedc8c7..32d1680a2a 100644 --- a/tox.ini +++ b/tox.ini @@ -73,7 +73,7 @@ healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(sock [docker:lots-of-pythons] name = lots-of-pythons -image = redisfab/lots-of-pythons +image = redisfab/lots-of-pythons:latest volumes = bind:rw:{toxinidir}:/data From c2d4621d5da2f4506fb484eb787f004a235c76b1 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Thu, 2 Dec 2021 18:53:00 +0100 Subject: [PATCH 0289/1164] Adding ROLE Command (#1610) Co-authored-by: Chayim --- redis/commands/core.py | 16 +++++++++++++++- tests/test_commands.py | 7 +++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index 462fba7f82..fec3095cc5 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -333,6 +333,16 @@ def bgsave(self, schedule=True, **kwargs): pieces.append("SCHEDULE") return self.execute_command("BGSAVE", *pieces, **kwargs) + def role(self): + """ + Provide information on the role of a Redis instance in + the context of replication, by returning if the instance + is currently a master, slave, or sentinel. + + For more information check https://redis.io/commands/role + """ + return self.execute_command("ROLE") + def client_kill(self, address, **kwargs): """Disconnects the client at ``address`` (ip:port) @@ -864,11 +874,15 @@ def slowlog_get(self, num=None, **kwargs): For more information check https://redis.io/commands/slowlog-get """ + from redis.client import NEVER_DECODE + args = ["SLOWLOG GET"] if num is not None: args.append(num) decode_responses = self.get_connection_kwargs().get("decode_responses", False) - return self.execute_command(*args, decode_responses=decode_responses, **kwargs) + if decode_responses is True: + kwargs[NEVER_DECODE] = [] + return self.execute_command(*args, **kwargs) def slowlog_len(self, **kwargs): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index 7c7d0f3d9e..556df840ad 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -653,6 +653,13 @@ def test_ping(self, r): def test_quit(self, r): assert r.quit() + @skip_if_server_version_lt("2.8.12") + @pytest.mark.onlynoncluster + def test_role(self, r): + assert r.role()[0] == b"master" + assert isinstance(r.role()[1], int) + assert isinstance(r.role()[2], list) + @pytest.mark.onlynoncluster def test_slowlog_get(self, r, slowlog): assert r.slowlog_reset() From b7ffec08da97b71b10bbd139b32ff82d33d907f1 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Thu, 2 Dec 2021 22:54:08 +0200 Subject: [PATCH 0290/1164] Improved RedisCluster's reinitialize_steps and documentation (#1765) --- redis/cluster.py | 36 +++++++++++++++++++++++++++++------- tests/test_cluster.py | 20 +++++++++++++++++++- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/redis/cluster.py b/redis/cluster.py index eead2b4dfe..c5634a05d8 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -414,14 +414,25 @@ def __init__( stale data. When set to true, read commands will be assigned between the primary and its replications in a Round-Robin manner. - :cluster_error_retry_attempts: 'int' + :cluster_error_retry_attempts: 'int' Retry command execution attempts when encountering ClusterDownError or ConnectionError - :retry_on_timeout: 'bool' + :retry_on_timeout: 'bool' To specify a retry policy, first set `retry_on_timeout` to `True` then set `retry` to a valid `Retry` object - :retry: 'Retry' + :retry: 'Retry' a `Retry` object + :reinitialize_steps: 'int' + Specifies the number of MOVED errors that need to occur before + reinitializing the whole cluster topology. If a MOVED error occurs + and the cluster does not need to be reinitialized on this current + error handling, only the MOVED slot will be patched with the + redirected node. + To reinitialize the cluster on every MOVED error, set + reinitialize_steps to 1. + To avoid reinitializing the cluster on moved errors, set + reinitialize_steps to 0. + :**kwargs: Extra arguments that will be sent into Redis instance when created (See Official redis-py doc for supported kwargs @@ -727,7 +738,9 @@ def _determine_nodes(self, *args, **kwargs): return [node] def _should_reinitialized(self): - # In order not to reinitialize the cluster, the user can set + # To reinitialize the cluster on every MOVED error, + # set reinitialize_steps to 1. + # To avoid reinitializing the cluster on moved errors, set # reinitialize_steps to 0. if self.reinitialize_steps == 0: return False @@ -958,8 +971,8 @@ def _execute_command(self, target_node, *args, **kwargs): # redirected node output and try again. If MovedError exceeds # 'reinitialize_steps' number of times, we will force # reinitializing the tables, and then try again. - # 'reinitialize_steps' counter will increase faster when the - # same client object is shared between multiple threads. To + # 'reinitialize_steps' counter will increase faster when + # the same client object is shared between multiple threads. To # reduce the frequency you can set this variable in the # RedisCluster constructor. log.exception("MovedError") @@ -1055,6 +1068,10 @@ def __repr__(self): def __eq__(self, obj): return isinstance(obj, ClusterNode) and obj.name == self.name + def __del__(self): + if self.redis_connection is not None: + self.redis_connection.close() + class LoadBalancer: """ @@ -1300,6 +1317,11 @@ def initialize(self): startup_node.host, startup_node.port, **copy_kwargs ) self.startup_nodes[startup_node.name].redis_connection = r + # Make sure cluster mode is enabled on this node + if bool(r.info().get("cluster_enabled")) is False: + raise RedisClusterException( + "Cluster mode is not enabled on this node" + ) cluster_slots = r.execute_command("CLUSTER SLOTS") startup_nodes_reachable = True except (ConnectionError, TimeoutError) as e: @@ -1327,7 +1349,7 @@ def initialize(self): message = e.__str__() raise RedisClusterException( 'ERROR sending "cluster slots" command to redis ' - f"server: {startup_node}. error: {message}" + f"server {startup_node.name}. error: {message}" ) # CLUSTER SLOTS command results in the following output: diff --git a/tests/test_cluster.py b/tests/test_cluster.py index b76ed80958..4087d33542 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -84,6 +84,7 @@ def get_mocked_redis_client(func=None, *args, **kwargs): """ cluster_slots = kwargs.pop("cluster_slots", default_cluster_slots) coverage_res = kwargs.pop("coverage_result", "yes") + cluster_enabled = kwargs.pop("cluster_enabled", True) with patch.object(Redis, "execute_command") as execute_command_mock: def execute_command(*_args, **_kwargs): @@ -92,7 +93,9 @@ def execute_command(*_args, **_kwargs): return mock_cluster_slots elif _args[0] == "COMMAND": return {"get": [], "set": []} - elif _args[1] == "cluster-require-full-coverage": + elif _args[0] == "INFO": + return {"cluster_enabled": cluster_enabled} + elif len(_args) > 1 and _args[1] == "cluster-require-full-coverage": return {"cluster-require-full-coverage": coverage_res} elif func is not None: return func(*args, **kwargs) @@ -1974,6 +1977,17 @@ def test_init_slots_cache(self): assert len(n_manager.nodes_cache) == 6 + def test_init_slots_cache_cluster_mode_disabled(self): + """ + Test that creating a RedisCluster failes if one of the startup nodes + has cluster mode disabled + """ + with pytest.raises(RedisClusterException) as e: + get_mocked_redis_client( + host=default_host, port=default_port, cluster_enabled=False + ) + assert "Cluster mode is not enabled on this node" in str(e.value) + def test_empty_startup_nodes(self): """ It should not be possible to create a node manager with no nodes @@ -2044,6 +2058,8 @@ def create_mocked_redis_node(host, port, **kwargs): def execute_command(*args, **kwargs): if args[0] == "CLUSTER SLOTS": return result + elif args[0] == "INFO": + return {"cluster_enabled": True} elif args[1] == "cluster-require-full-coverage": return {"cluster-require-full-coverage": "yes"} else: @@ -2108,6 +2124,8 @@ def execute_command(*args, **kwargs): ["127.0.0.1", 7002, "node_2"], ], ] + elif args[0] == "INFO": + return {"cluster_enabled": True} elif args[1] == "cluster-require-full-coverage": return {"cluster-require-full-coverage": "yes"} From 11b14630a6845c28acfd4220b72ed62d72913305 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Thu, 2 Dec 2021 22:59:06 +0200 Subject: [PATCH 0291/1164] Added support for MONITOR in clusters (#1756) --- redis/cluster.py | 17 +++++++++++ tests/conftest.py | 20 +++++++------ tests/test_cluster.py | 54 ++++++++++++++++++++++++++++++++++- tests/test_commands.py | 53 +++++++++++++++++----------------- tests/test_connection_pool.py | 12 ++++---- tests/test_monitor.py | 4 +-- tests/test_pubsub.py | 2 +- 7 files changed, 117 insertions(+), 45 deletions(-) diff --git a/redis/cluster.py b/redis/cluster.py index c5634a05d8..b1adeb7341 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -670,6 +670,23 @@ def set_default_node(self, node): log.info(f"Changed the default cluster node to {node}") return True + def monitor(self, target_node=None): + """ + Returns a Monitor object for the specified target node. + The default cluster node will be selected if no target node was + specified. + Monitor is useful for handling the MONITOR command to the redis server. + next_command() method returns one command from monitor + listen() method yields commands from monitor. + """ + if target_node is None: + target_node = self.get_default_node() + if target_node.redis_connection is None: + raise RedisClusterException( + f"Cluster Node {target_node.name} has no redis_connection" + ) + return target_node.redis_connection.monitor() + def pubsub(self, node=None, host=None, port=None, **kwargs): """ Allows passing a ClusterNode, or host&port, to get a pubsub instance diff --git a/tests/conftest.py b/tests/conftest.py index 24783c0466..ab29ee4fcd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -151,12 +151,12 @@ def skip_ifmodversion_lt(min_version: str, module_name: str): raise AttributeError(f"No redis module named {module_name}") -def skip_if_redis_enterprise(func): +def skip_if_redis_enterprise(): check = REDIS_INFO["enterprise"] is True return pytest.mark.skipif(check, reason="Redis enterprise") -def skip_ifnot_redis_enterprise(func): +def skip_ifnot_redis_enterprise(): check = REDIS_INFO["enterprise"] is False return pytest.mark.skipif(check, reason="Not running in redis enterprise") @@ -324,16 +324,18 @@ def master_host(request): yield parts.hostname, parts.port -def wait_for_command(client, monitor, command): +def wait_for_command(client, monitor, command, key=None): # issue a command with a key name that's local to this process. # if we find a command with our key before the command we're waiting # for, something went wrong - redis_version = REDIS_INFO["version"] - if LooseVersion(redis_version) >= LooseVersion("5.0.0"): - id_str = str(client.client_id()) - else: - id_str = f"{random.randrange(2 ** 32):08x}" - key = f"__REDIS-PY-{id_str}__" + if key is None: + # generate key + redis_version = REDIS_INFO["version"] + if LooseVersion(redis_version) >= LooseVersion("5.0.0"): + id_str = str(client.client_id()) + else: + id_str = f"{random.randrange(2 ** 32):08x}" + key = f"__REDIS-PY-{id_str}__" client.get(key) while True: monitor_response = monitor.next_command() diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 4087d33542..15d8ac6c35 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -36,6 +36,7 @@ skip_if_redis_enterprise, skip_if_server_version_lt, skip_unless_arch_bits, + wait_for_command, ) default_host = "127.0.0.1" @@ -1774,7 +1775,7 @@ def test_cluster_randomkey(self, r): assert r.randomkey(target_nodes=node) in (b"{foo}a", b"{foo}b", b"{foo}c") @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_acl_log(self, r, request): key = "{cache}:" node = r.get_node_from_key(key) @@ -2631,3 +2632,54 @@ def test_readonly_pipeline_from_readonly_client(self, request): if executed_on_replica: break assert executed_on_replica is True + + +@pytest.mark.onlycluster +class TestClusterMonitor: + def test_wait_command_not_found(self, r): + "Make sure the wait_for_command func works when command is not found" + key = "foo" + node = r.get_node_from_key(key) + with r.monitor(target_node=node) as m: + response = wait_for_command(r, m, "nothing", key=key) + assert response is None + + def test_response_values(self, r): + db = 0 + key = "foo" + node = r.get_node_from_key(key) + with r.monitor(target_node=node) as m: + r.ping(target_nodes=node) + response = wait_for_command(r, m, "PING", key=key) + assert isinstance(response["time"], float) + assert response["db"] == db + assert response["client_type"] in ("tcp", "unix") + assert isinstance(response["client_address"], str) + assert isinstance(response["client_port"], str) + assert response["command"] == "PING" + + def test_command_with_quoted_key(self, r): + key = "{foo}1" + node = r.get_node_from_key(key) + with r.monitor(node) as m: + r.get('{foo}"bar') + response = wait_for_command(r, m, 'GET {foo}"bar', key=key) + assert response["command"] == 'GET {foo}"bar' + + def test_command_with_binary_data(self, r): + key = "{foo}1" + node = r.get_node_from_key(key) + with r.monitor(target_node=node) as m: + byte_string = b"{foo}bar\x92" + r.get(byte_string) + response = wait_for_command(r, m, "GET {foo}bar\\x92", key=key) + assert response["command"] == "GET {foo}bar\\x92" + + def test_command_with_escaped_data(self, r): + key = "{foo}1" + node = r.get_node_from_key(key) + with r.monitor(target_node=node) as m: + byte_string = b"{foo}bar\\x92" + r.get(byte_string) + response = wait_for_command(r, m, "GET {foo}bar\\\\x92", key=key) + assert response["command"] == "GET {foo}bar\\\\x92" diff --git a/tests/test_commands.py b/tests/test_commands.py index 556df840ad..936cbe5a40 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -84,7 +84,7 @@ def test_acl_cat_with_category(self, r): assert "get" in commands @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_acl_deluser(self, r, request): username = "redis-py-user" @@ -109,7 +109,7 @@ def teardown(): assert r.acl_getuser(users[4]) is None @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_acl_genpass(self, r): password = r.acl_genpass() assert isinstance(password, str) @@ -123,7 +123,7 @@ def test_acl_genpass(self, r): assert isinstance(password, str) @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_acl_getuser_setuser(self, r, request): username = "redis-py-user" @@ -236,7 +236,7 @@ def test_acl_help(self, r): assert len(res) != 0 @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_acl_list(self, r, request): username = "redis-py-user" @@ -250,7 +250,8 @@ def teardown(): assert len(users) == 2 @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() + @pytest.mark.onlynoncluster def test_acl_log(self, r, request): username = "redis-py-user" @@ -292,7 +293,7 @@ def teardown(): assert r.acl_log_reset() @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_acl_setuser_categories_without_prefix_fails(self, r, request): username = "redis-py-user" @@ -305,7 +306,7 @@ def teardown(): r.acl_setuser(username, categories=["list"]) @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_acl_setuser_commands_without_prefix_fails(self, r, request): username = "redis-py-user" @@ -318,7 +319,7 @@ def teardown(): r.acl_setuser(username, commands=["get"]) @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request): username = "redis-py-user" @@ -363,7 +364,7 @@ def test_client_list_types_not_replica(self, r): clients = r.client_list(_type=client_type) assert isinstance(clients, list) - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_client_list_replica(self, r): clients = r.client_list(_type="replica") assert isinstance(clients, list) @@ -529,7 +530,7 @@ def test_client_kill_filter_by_laddr(self, r, r2): assert r.client_kill_filter(laddr=client_2_addr) @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_client_kill_filter_by_user(self, r, request): killuser = "user_to_kill" r.acl_setuser( @@ -549,7 +550,7 @@ def test_client_kill_filter_by_user(self, r, request): @pytest.mark.onlynoncluster @skip_if_server_version_lt("2.9.50") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_client_pause(self, r): assert r.client_pause(1) assert r.client_pause(timeout=1) @@ -558,7 +559,7 @@ def test_client_pause(self, r): @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.2.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_client_unpause(self, r): assert r.client_unpause() == b"OK" @@ -578,7 +579,7 @@ def test_client_reply(self, r, r_timeout): @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_client_getredir(self, r): assert isinstance(r.client_getredir(), int) assert r.client_getredir() == -1 @@ -590,7 +591,7 @@ def test_config_get(self, r): # assert data['maxmemory'].isdigit() @pytest.mark.onlynoncluster - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_config_resetstat(self, r): r.ping() prior_commands_processed = int(r.info()["total_commands_processed"]) @@ -599,7 +600,7 @@ def test_config_resetstat(self, r): reset_commands_processed = int(r.info()["total_commands_processed"]) assert reset_commands_processed < prior_commands_processed - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_config_set(self, r): r.config_set("timeout", 70) assert r.config_get()["timeout"] == "70" @@ -626,7 +627,7 @@ def test_info(self, r): assert "redis_version" in info.keys() @pytest.mark.onlynoncluster - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_lastsave(self, r): assert isinstance(r.lastsave(), datetime.datetime) @@ -731,7 +732,7 @@ def test_time(self, r): assert isinstance(t[0], int) assert isinstance(t[1], int) - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_bgsave(self, r): assert r.bgsave() time.sleep(0.3) @@ -1312,7 +1313,7 @@ def test_stralgo_lcs(self, r): value2 = "mynewtext" res = "mytext" - if skip_if_redis_enterprise(None).args[0] is True: + if skip_if_redis_enterprise().args[0] is True: with pytest.raises(redis.exceptions.ResponseError): assert r.stralgo("LCS", value1, value2) == res return @@ -1354,7 +1355,7 @@ def test_strlen(self, r): def test_substr(self, r): r["a"] = "0123456789" - if skip_if_redis_enterprise(None).args[0] is True: + if skip_if_redis_enterprise().args[0] is True: with pytest.raises(redis.exceptions.ResponseError): assert r.substr("a", 0) == b"0123456789" return @@ -2665,7 +2666,7 @@ def test_cluster_slaves(self, mock_cluster_resp_slaves): @pytest.mark.onlynoncluster @skip_if_server_version_lt("3.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_readwrite(self, r): assert r.readwrite() @@ -4016,7 +4017,7 @@ def test_memory_doctor(self, r): @skip_if_server_version_lt("4.0.0") def test_memory_malloc_stats(self, r): - if skip_if_redis_enterprise(None).args[0] is True: + if skip_if_redis_enterprise().args[0] is True: with pytest.raises(redis.exceptions.ResponseError): assert r.memory_malloc_stats() return @@ -4029,7 +4030,7 @@ def test_memory_stats(self, r): # has data r.set("foo", "bar") - if skip_if_redis_enterprise(None).args[0] is True: + if skip_if_redis_enterprise().args[0] is True: with pytest.raises(redis.exceptions.ResponseError): stats = r.memory_stats() return @@ -4047,7 +4048,7 @@ def test_memory_usage(self, r): @pytest.mark.onlynoncluster @skip_if_server_version_lt("4.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_module_list(self, r): assert isinstance(r.module_list(), list) for x in r.module_list(): @@ -4088,7 +4089,7 @@ def test_command(self, r): @pytest.mark.onlynoncluster @skip_if_server_version_lt("4.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_module(self, r): with pytest.raises(redis.exceptions.ModuleError) as excinfo: r.module_load("/some/fake/path") @@ -4144,7 +4145,7 @@ def test_restore_frequency(self, r): @pytest.mark.onlynoncluster @skip_if_server_version_lt("5.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_replicaof(self, r): with pytest.raises(redis.ResponseError): assert r.replicaof("NO ONE") @@ -4226,7 +4227,7 @@ def test_22_info(self, r): assert "6" in parsed["allocation_stats"] assert ">=256" in parsed["allocation_stats"] - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_large_responses(self, r): "The PythonParser has some special cases for return values > 1MB" # load up 5MB of data into a key diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 138fcad4c9..3e1fbaed27 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -514,7 +514,7 @@ def test_on_connect_error(self): @pytest.mark.onlynoncluster @skip_if_server_version_lt("2.8.8") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_busy_loading_disconnects_socket(self, r): """ If Redis raises a LOADING error, the connection should be @@ -526,7 +526,7 @@ def test_busy_loading_disconnects_socket(self, r): @pytest.mark.onlynoncluster @skip_if_server_version_lt("2.8.8") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_busy_loading_from_pipeline_immediate_command(self, r): """ BusyLoadingErrors should raise from Pipelines that execute a @@ -542,7 +542,7 @@ def test_busy_loading_from_pipeline_immediate_command(self, r): @pytest.mark.onlynoncluster @skip_if_server_version_lt("2.8.8") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_busy_loading_from_pipeline(self, r): """ BusyLoadingErrors should be raised from a pipeline execution @@ -558,7 +558,7 @@ def test_busy_loading_from_pipeline(self, r): assert not pool._available_connections[0]._sock @skip_if_server_version_lt("2.8.8") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_read_only_error(self, r): "READONLY errors get turned in ReadOnlyError exceptions" with pytest.raises(redis.ReadOnlyError): @@ -584,7 +584,7 @@ def test_connect_from_url_unix(self): "path=/path/to/socket,db=0", ) - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_connect_no_auth_supplied_when_required(self, r): """ AuthenticationError should be raised when the server requires a @@ -595,7 +595,7 @@ def test_connect_no_auth_supplied_when_required(self, r): "DEBUG", "ERROR", "ERR Client sent AUTH, but no password is set" ) - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_connect_invalid_password_supplied(self, r): "AuthenticationError should be raised when sending the wrong password" with pytest.raises(redis.AuthenticationError): diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 40d9e43094..9b07c80201 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -47,7 +47,7 @@ def test_command_with_escaped_data(self, r): response = wait_for_command(r, m, "GET foo\\\\x92") assert response["command"] == "GET foo\\\\x92" - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_lua_script(self, r): with r.monitor() as m: script = 'return redis.call("GET", "foo")' @@ -58,7 +58,7 @@ def test_lua_script(self, r): assert response["client_address"] == "lua" assert response["client_port"] == "" - @skip_ifnot_redis_enterprise + @skip_ifnot_redis_enterprise() def test_lua_script_in_enterprise(self, r): with r.monitor() as m: script = 'return redis.call("GET", "foo")' diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 6df0fafd4b..20ae0a05c1 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -530,7 +530,7 @@ def test_send_pubsub_ping_message(self, r): @pytest.mark.onlynoncluster class TestPubSubConnectionKilled: @skip_if_server_version_lt("3.0.0") - @skip_if_redis_enterprise + @skip_if_redis_enterprise() def test_connection_error_raised_when_connection_dies(self, r): p = r.pubsub() p.subscribe("foo") From 748c8d1029f018e752b4039253dcb8de2fc57a34 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Wed, 8 Dec 2021 10:17:38 +0200 Subject: [PATCH 0292/1164] Fix cluster ACL tests (#1774) --- tests/test_cluster.py | 2 +- tests/test_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 15d8ac6c35..e1c8c34768 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -1789,7 +1789,7 @@ def teardown(): username, enabled=True, reset=True, - commands=["+get", "+set", "+select", "+cluster", "+command"], + commands=["+get", "+set", "+select", "+cluster", "+command", "+info"], keys=["{cache}:*"], nopass=True, target_nodes="primaries", diff --git a/tests/test_commands.py b/tests/test_commands.py index 936cbe5a40..eab9072c0d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -537,7 +537,7 @@ def test_client_kill_filter_by_user(self, r, request): killuser, enabled=True, reset=True, - commands=["+get", "+set", "+select", "+cluster", "+command"], + commands=["+get", "+set", "+select", "+cluster", "+command", "+info"], keys=["cache:*"], nopass=True, ) From bba31cde198fa16520eaf3cc272b403d13e00fdc Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 8 Dec 2021 13:04:51 +0200 Subject: [PATCH 0293/1164] Removing distutils from tests (#1773) Co-authored-by: Bar Shaul <88437685+barshaul@users.noreply.github.com> --- tests/conftest.py | 8 ++++---- tests/test_search.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ab29ee4fcd..4b5f6cbd5c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,10 @@ import random import time -from distutils.version import LooseVersion from unittest.mock import Mock from urllib.parse import urlparse import pytest +from packaging.version import Version import redis from redis.backoff import NoBackoff @@ -117,13 +117,13 @@ def wait_for_cluster_creation(redis_url, cluster_nodes, timeout=20): def skip_if_server_version_lt(min_version): redis_version = REDIS_INFO["version"] - check = LooseVersion(redis_version) < LooseVersion(min_version) + check = Version(redis_version) < Version(min_version) return pytest.mark.skipif(check, reason=f"Redis version required >= {min_version}") def skip_if_server_version_gte(min_version): redis_version = REDIS_INFO["version"] - check = LooseVersion(redis_version) >= LooseVersion(min_version) + check = Version(redis_version) >= Version(min_version) return pytest.mark.skipif(check, reason=f"Redis version required < {min_version}") @@ -331,7 +331,7 @@ def wait_for_command(client, monitor, command, key=None): if key is None: # generate key redis_version = REDIS_INFO["version"] - if LooseVersion(redis_version) >= LooseVersion("5.0.0"): + if Version(redis_version) >= Version("5.0.0"): id_str = str(client.client_id()) else: id_str = f"{random.randrange(2 ** 32):08x}" diff --git a/tests/test_search.py b/tests/test_search.py index 1a22b665a8..7d666cbd96 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1419,7 +1419,7 @@ def test_profile(client): assert det["Iterators profile"]["Counter"] == 2.0 assert len(det["Iterators profile"]["Child iterators"]) == 2 assert det["Iterators profile"]["Type"] == "UNION" - assert det["Parsing time"] < 0.3 + assert det["Parsing time"] < 0.5 assert len(res.docs) == 2 # check also the search result # check using AggregateRequest @@ -1431,7 +1431,7 @@ def test_profile(client): res, det = client.ft().profile(req) assert det["Iterators profile"]["Counter"] == 2.0 assert det["Iterators profile"]["Type"] == "WILDCARD" - assert det["Parsing time"] < 0.3 + assert det["Parsing time"] < 0.5 assert len(res.rows) == 2 # check also the search result From 291baa93b8712d104ce50a61f52e23b68e2b7a99 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 8 Dec 2021 18:15:32 +0200 Subject: [PATCH 0294/1164] Fixing the license link in the readme (#1778) --- README.md | 104 +++++++++++++++++++++++++++--------------------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index f9d6309231..f8e76701fe 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ The Python interface to the Redis key-value store. -[![CI](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster) -[![docs](https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) -[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE.txt) -[![pypi](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/) +[![CI](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster) +[![docs](https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) +[![pypi](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/) [![codecov](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg?token=yenl5fzxxr)](https://codecov.io/gh/redis/redis-py) [![Total alerts](https://img.shields.io/lgtm/alerts/g/redis/redis-py.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/redis/redis-py/alerts/) @@ -72,7 +72,7 @@ specified. The default encoding is utf-8, but this can be customized by specifiying the encoding argument for the redis.Redis class. The encoding will be used to automatically encode any -strings passed to commands, such as key names and values. +strings passed to commands, such as key names and values. -------------------- @@ -951,16 +951,16 @@ C 3 redis-py is now supports cluster mode and provides a client for [Redis Cluster](). -The cluster client is based on Grokzen's -[redis-py-cluster](https://github.com/Grokzen/redis-py-cluster), has added bug -fixes, and now supersedes that library. Support for these changes is thanks to +The cluster client is based on Grokzen's +[redis-py-cluster](https://github.com/Grokzen/redis-py-cluster), has added bug +fixes, and now supersedes that library. Support for these changes is thanks to his contributions. **Create RedisCluster:** -Connecting redis-py to a Redis Cluster instance(s) requires at a minimum a -single node for cluster discovery. There are multiple ways in which a cluster +Connecting redis-py to a Redis Cluster instance(s) requires at a minimum a +single node for cluster discovery. There are multiple ways in which a cluster instance can be created: - Using 'host' and 'port' arguments: @@ -1001,16 +1001,16 @@ RedisCluster instance can be directly used to execute Redis commands. When a command is being executed through the cluster instance, the target node(s) will be internally determined. When using a key-based command, the target node will be the node that holds the key's slot. -Cluster management commands and other commands that are not key-based have a -parameter called 'target_nodes' where you can specify which nodes to execute -the command on. In the absence of target_nodes, the command will be executed -on the default cluster node. As part of cluster instance initialization, the -cluster's default node is randomly selected from the cluster's primaries, and -will be updated upon reinitialization. Using r.get_default_node(), you can -get the cluster's default node, or you can change it using the +Cluster management commands and other commands that are not key-based have a +parameter called 'target_nodes' where you can specify which nodes to execute +the command on. In the absence of target_nodes, the command will be executed +on the default cluster node. As part of cluster instance initialization, the +cluster's default node is randomly selected from the cluster's primaries, and +will be updated upon reinitialization. Using r.get_default_node(), you can +get the cluster's default node, or you can change it using the 'set_default_node' method. -The 'target_nodes' parameter is explained in the following section, +The 'target_nodes' parameter is explained in the following section, 'Specifying Target Nodes'. ``` pycon @@ -1030,8 +1030,8 @@ The 'target_nodes' parameter is explained in the following section, **Specifying Target Nodes:** -As mentioned above, all non key-based RedisCluster commands accept the kwarg -parameter 'target_nodes' that specifies the node/nodes that the command should +As mentioned above, all non key-based RedisCluster commands accept the kwarg +parameter 'target_nodes' that specifies the node/nodes that the command should be executed on. The best practice is to specify target nodes using RedisCluster class's node flags: PRIMARIES, REPLICAS, ALL_NODES, RANDOM. When a nodes flag is passed @@ -1070,7 +1070,7 @@ the relevant cluster or connection error will be returned. >>> rc.info(target_nodes=subset_primaries) ``` -In addition, the RedisCluster instance can query the Redis instance of a +In addition, the RedisCluster instance can query the Redis instance of a specific node and execute commands on that node directly. The Redis client, however, does not handle cluster failures and retries. @@ -1094,7 +1094,7 @@ By using RedisCluster client, you can use the known functions (e.g. mget, mset) to perform an atomic multi-key operation. However, you must ensure all keys are mapped to the same slot, otherwise a RedisClusterException will be thrown. Redis Cluster implements a concept called hash tags that can be used in order -to force certain keys to be stored in the same hash slot, see +to force certain keys to be stored in the same hash slot, see [Keys hash tag](https://redis.io/topics/cluster-spec#keys-hash-tags). You can also use nonatomic for some of the multikey operations, and pass keys that aren't mapped to the same slot. The client will then map the keys to the @@ -1121,15 +1121,15 @@ first command execution. The node will be determined by: 1. Hashing the channel name in the request to find its keyslot 2. Selecting a node that handles the keyslot: If read_from_replicas is set to true, a replica can be selected. - + *Known limitations with pubsub:* -Pattern subscribe and publish do not currently work properly due to key slots. -If we hash a pattern like fo* we will receive a keyslot for that string but -there are endless possibilities for channel names based on this pattern - -unknowable in advance. This feature is not disabled but the commands are not +Pattern subscribe and publish do not currently work properly due to key slots. +If we hash a pattern like fo* we will receive a keyslot for that string but +there are endless possibilities for channel names based on this pattern - +unknowable in advance. This feature is not disabled but the commands are not currently recommended for use. -See [redis-py-cluster documentation](https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html) +See [redis-py-cluster documentation](https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html) for more. ``` pycon @@ -1142,22 +1142,22 @@ See [redis-py-cluster documentation](https://redis-py-cluster.readthedocs.io/en/ **Read Only Mode** -By default, Redis Cluster always returns MOVE redirection response on accessing -a replica node. You can overcome this limitation and scale read commands by +By default, Redis Cluster always returns MOVE redirection response on accessing +a replica node. You can overcome this limitation and scale read commands by triggering READONLY mode. -To enable READONLY mode pass read_from_replicas=True to RedisCluster +To enable READONLY mode pass read_from_replicas=True to RedisCluster constructor. When set to true, read commands will be assigned between the -primary and its replications in a Round-Robin manner. +primary and its replications in a Round-Robin manner. -READONLY mode can be set at runtime by calling the readonly() method with -target_nodes='replicas', and read-write access can be restored by calling the +READONLY mode can be set at runtime by calling the readonly() method with +target_nodes='replicas', and read-write access can be restored by calling the readwrite() method. ``` pycon >>> from cluster import RedisCluster as Redis # Use 'debug' log level to print the node that the command is executed on - >>> rc_readonly = Redis(startup_nodes=startup_nodes, + >>> rc_readonly = Redis(startup_nodes=startup_nodes, read_from_replicas=True) >>> rc_readonly.set('{foo}1', 'bar1') >>> for i in range(0, 4): @@ -1173,15 +1173,15 @@ readwrite() method. **Cluster Pipeline** -ClusterPipeline is a subclass of RedisCluster that provides support for Redis -pipelines in cluster mode. -When calling the execute() command, all the commands are grouped by the node -on which they will be executed, and are then executed by the respective nodes -in parallel. The pipeline instance will wait for all the nodes to respond -before returning the result to the caller. Command responses are returned as a +ClusterPipeline is a subclass of RedisCluster that provides support for Redis +pipelines in cluster mode. +When calling the execute() command, all the commands are grouped by the node +on which they will be executed, and are then executed by the respective nodes +in parallel. The pipeline instance will wait for all the nodes to respond +before returning the result to the caller. Command responses are returned as a list sorted in the same order in which they were sent. -Pipelines can be used to dramatically increase the throughput of Redis Cluster -by significantly reducing the the number of network round trips between the +Pipelines can be used to dramatically increase the throughput of Redis Cluster +by significantly reducing the the number of network round trips between the client and the server. ``` pycon @@ -1198,16 +1198,16 @@ client and the server. Please note: - RedisCluster pipelines currently only support key-based commands. - The pipeline gets its 'read_from_replicas' value from the cluster's parameter. -Thus, if read from replications is enabled in the cluster instance, the pipeline +Thus, if read from replications is enabled in the cluster instance, the pipeline will also direct read commands to replicas. -- The 'transcation' option is NOT supported in cluster-mode. In non-cluster mode, -the 'transaction' option is available when executing pipelines. This wraps the -pipeline commands with MULTI/EXEC commands, and effectively turns the pipeline -commands into a single transaction block. This means that all commands are -executed sequentially without any interruptions from other clients. However, -in cluster-mode this is not possible, because commands are partitioned -according to their respective destination nodes. This means that we can not -turn the pipeline commands into one transaction block, because in most cases +- The 'transcation' option is NOT supported in cluster-mode. In non-cluster mode, +the 'transaction' option is available when executing pipelines. This wraps the +pipeline commands with MULTI/EXEC commands, and effectively turns the pipeline +commands into a single transaction block. This means that all commands are +executed sequentially without any interruptions from other clients. However, +in cluster-mode this is not possible, because commands are partitioned +according to their respective destination nodes. This means that we can not +turn the pipeline commands into one transaction block, because in most cases they are split up into several smaller pipelines. From a58f4235e554cb50b312caf1a9076114c77d0529 Mon Sep 17 00:00:00 2001 From: Sebastian Pipping Date: Thu, 9 Dec 2021 08:45:14 +0100 Subject: [PATCH 0295/1164] Add packaging to setup_requires, and use >= to play nice to setup.py (fixes #1625) (#1780) --- setup.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 58d753fb3a..b9c2e3e315 100644 --- a/setup.py +++ b/setup.py @@ -26,9 +26,12 @@ author="Redis Inc.", author_email="oss@redis.com", python_requires=">=3.6", + setup_requires=[ + "packaging>=21.3", + ], install_requires=[ - "deprecated==1.2.3", - "packaging==21.3", + "deprecated>=1.2.3", + "packaging>=21.3", ], classifiers=[ "Development Status :: 5 - Production/Stable", From 12c17bfc436ea6784bbc8b2d327d981520858eb7 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 9 Dec 2021 09:49:35 +0200 Subject: [PATCH 0296/1164] Adding cluster, bloom, and graph docs (#1779) --- docs/index.rst | 20 ++- docs/redis_cluster_commands.rst | 7 + ...s_core_commands.rst => redis_commands.rst} | 6 +- docs/redismodules.rst | 123 +++++++++++++++++- redis/commands/bf/commands.py | 6 +- redis/commands/graph/commands.py | 2 + 6 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 docs/redis_cluster_commands.rst rename docs/{redis_core_commands.rst => redis_commands.rst} (88%) diff --git a/docs/index.rst b/docs/index.rst index 8e243f3e28..d088708e50 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,21 +16,30 @@ redis-py can be installed using pip via ``pip install redis``. Quickly connecting to redis -************ +*************************** There are two quick ways to connect to Redis. -Assuming you run Redis on localhost:6379 (the default):: +**Assuming you run Redis on localhost:6379 (the default)** + +.. code-block:: python + import redis r = redis.Redis() r.ping() -Running redis on foo.bar.com, port 12345:: +**Running redis on foo.bar.com, port 12345** + +.. code-block:: python + import redis r = redis.Redis(host='foo.bar.com', port=12345) r.ping() -Another example with foo.bar.com, port 12345:: +**Another example with foo.bar.com, port 12345** + +.. code-block:: python + import redis r = redis.from_url('redis://foo.bar.com:12345') r.ping() @@ -47,7 +56,8 @@ Redis Command Functions .. toctree:: :maxdepth: 2 - redis_core_commands + redis_commands + redis_cluster_commands sentinel_commands redismodules diff --git a/docs/redis_cluster_commands.rst b/docs/redis_cluster_commands.rst new file mode 100644 index 0000000000..de520de905 --- /dev/null +++ b/docs/redis_cluster_commands.rst @@ -0,0 +1,7 @@ +Redis Cluster Commands +###################### + +The following `Redis commands `_ are available within a `Redis Cluster `_. Generally they can be used as functions on your redis connection. + +.. autoclass:: redis.commands.cluster.RedisClusterCommands + :inherited-members: diff --git a/docs/redis_core_commands.rst b/docs/redis_commands.rst similarity index 88% rename from docs/redis_core_commands.rst rename to docs/redis_commands.rst index edfd7fe939..efb76e79c6 100644 --- a/docs/redis_core_commands.rst +++ b/docs/redis_commands.rst @@ -1,5 +1,5 @@ -Redis Core Commands -#################### +Redis Commands +############### The following functions can be used to replicate their equivalent `Redis command `_. Generally they can be used as functions on your redis connection. For the simplest example, see below: @@ -11,4 +11,4 @@ Getting and settings data in redis:: r.get('mykey') .. autoclass:: redis.commands.core.CoreCommands - :members: \ No newline at end of file + :inherited-members: diff --git a/docs/redismodules.rst b/docs/redismodules.rst index da8c36b7b4..0cb8c49660 100644 --- a/docs/redismodules.rst +++ b/docs/redismodules.rst @@ -5,15 +5,132 @@ Accessing redis module commands requires the installation of the supported `Redi RedisTimeSeries Commands ************************ + +These are the commands for interacting with the `RedisTimeSeries module `_. Below is a brief example, as well as documentation on the commands themselves. + + +**Create a timeseries object with 5 second retention** + +.. code-block:: python + + import redis + r = redis.Redis() + r.timeseries().create(2, retension_msecs=5) + .. automodule:: redis.commands.timeseries.commands - :members: TimeSeriesCommands + :members: TimeSeriesCommands + +----- RedisJSON Commands ****************** + +These are the commands for interacting with the `RedisJSON module `_. Below is a brief example, as well as documentation on the commands themselves. + +**Create a json object** + +.. code-block:: python + + import redis + r = redis.Redis() + r.json().set("mykey", ".", {"hello": "world", "i am": ["a", "json", "object!"]} + + .. automodule:: redis.commands.json.commands - :members: JSONCommands + :members: JSONCommands + +----- RediSearch Commands ******************* + +These are the commands for interacting with the `RediSearch module `_. Below is a brief example, as well as documentation on the commands themselves. + +**Create a search index, and display its information** + +.. code-block:: python + + import redis + r = redis.Redis() + r.ft().create_index(TextField("play", weight=5.0), TextField("ball")) + print(r.ft().info()) + + .. automodule:: redis.commands.search.commands - :members: SearchCommands \ No newline at end of file + :members: SearchCommands + +----- + +RedisGraph Commands +******************* + +These are the commands for interacting with the `RedisGraph module `_. Below is a brief example, as well as documentation on the commands themselves. + +**Create a graph, adding two nodes** + +.. code-block:: python + + import redis + from redis.graph.node import Node + + john = Node(label="person", properties={"name": "John Doe", "age": 33} + jane = Node(label="person", properties={"name": "Jane Doe", "age": 34} + + r = redis.Redis() + graph = r.graph() + graph.add_node(john) + graph.add_node(jane) + graph.add_node(pat) + graph.commit() + +.. automodule:: redis.commands.graph.node + :members: Node + +.. automodule:: redis.commands.graph.edge + :members: Edge + +.. automodule:: redis.commands.graph.commands + :members: GraphCommands + +----- + +RedisBloom Commands +******************* + +These are the commands for interacting with the `RedisBloom module `_. Below is a brief example, as well as documentation on the commands themselves. + +**Create and add to a bloom filter** + +.. code-block:: python + + import redis + filter = redis.bf().create("bloom", 0.01, 1000) + filter.add("bloom", "foo") + +**Create and add to a cuckoo filter** + +.. code-block:: python + + import redis + filter = redis.cf().create("cuckoo", 1000) + filter.add("cuckoo", "filter") + +**Create Count-Min Sketch and get information** + +.. code-block:: python + + import redis + r = redis.cms().initbydim("dim", 1000, 5) + r.cms().incrby("dim", ["foo"], [5]) + r.cms().info("dim") + +**Create a topk list, and access the results** + +.. code-block:: python + + import redis + r = redis.topk().reserve("mytopk", 3, 50, 4, 0.9) + info = r.topk().info("mytopk) + +.. automodule:: redis.commands.bf.commands + :members: BFCommands, CFCommands, CMSCommands, TOPKCommands diff --git a/redis/commands/bf/commands.py b/redis/commands/bf/commands.py index 3c8bf7f31e..7fc507d2d9 100644 --- a/redis/commands/bf/commands.py +++ b/redis/commands/bf/commands.py @@ -51,7 +51,7 @@ class BFCommands: - """RedisBloom commands.""" + """Bloom Filter commands.""" # region Bloom Filter Functions def create(self, key, errorRate, capacity, expansion=None, noScale=None): @@ -166,6 +166,7 @@ def info(self, key): class CFCommands: + """Cuckoo Filter commands.""" # region Cuckoo Filter Functions def create( @@ -283,6 +284,8 @@ def info(self, key): class TOPKCommands: + """TOP-k Filter commands.""" + def reserve(self, key, k, width, depth, decay): """ Create a new Top-K Filter `key` with desired probability of false @@ -432,6 +435,7 @@ def info(self, key): class CMSCommands: + """Count-Min Sketch Commands""" # region Count-Min Sketch Functions def initbydim(self, key, width, depth): diff --git a/redis/commands/graph/commands.py b/redis/commands/graph/commands.py index f0c1d687ed..e097936503 100644 --- a/redis/commands/graph/commands.py +++ b/redis/commands/graph/commands.py @@ -6,6 +6,8 @@ class GraphCommands: + """RedisGraph Commands""" + def commit(self): """ Create entire graph. From f82ab336c3e249ee871ee5e50e10c0de08c4f38a Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 13 Dec 2021 11:27:40 +0200 Subject: [PATCH 0297/1164] Disabling JSON.DEBUG tests (#1787) --- tests/test_json.py | 58 +++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/tests/test_json.py b/tests/test_json.py index 1686f9d05e..a99547dd04 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -134,14 +134,14 @@ def test_strappend(client): assert "foobar" == client.json().get("jsonkey", Path.rootPath()) -@pytest.mark.redismod -def test_debug(client): - client.json().set("str", Path.rootPath(), "foo") - assert 24 == client.json().debug("MEMORY", "str", Path.rootPath()) - assert 24 == client.json().debug("MEMORY", "str") - - # technically help is valid - assert isinstance(client.json().debug("HELP"), list) +# @pytest.mark.redismod +# def test_debug(client): +# client.json().set("str", Path.rootPath(), "foo") +# assert 24 == client.json().debug("MEMORY", "str", Path.rootPath()) +# assert 24 == client.json().debug("MEMORY", "str") +# +# # technically help is valid +# assert isinstance(client.json().debug("HELP"), list) @pytest.mark.redismod @@ -969,27 +969,27 @@ def test_toggle_dollar(client): client.json().toggle("non_existing_doc", "$..a") -@pytest.mark.redismod -def test_debug_dollar(client): - - jdata, jtypes = load_types_data("a") - - client.json().set("doc1", "$", jdata) - - # Test multi - assert client.json().debug("MEMORY", "doc1", "$..a") == [72, 24, 24, 16, 16, 1, 0] - - # Test single - assert client.json().debug("MEMORY", "doc1", "$.nested2.a") == [24] - - # Test legacy - assert client.json().debug("MEMORY", "doc1", "..a") == 72 - - # Test missing path (defaults to root) - assert client.json().debug("MEMORY", "doc1") == 72 - - # Test missing key - assert client.json().debug("MEMORY", "non_existing_doc", "$..a") == [] +# @pytest.mark.redismod +# def test_debug_dollar(client): +# +# jdata, jtypes = load_types_data("a") +# +# client.json().set("doc1", "$", jdata) +# +# # Test multi +# assert client.json().debug("MEMORY", "doc1", "$..a") == [72, 24, 24, 16, 16, 1, 0] +# +# # Test single +# assert client.json().debug("MEMORY", "doc1", "$.nested2.a") == [24] +# +# # Test legacy +# assert client.json().debug("MEMORY", "doc1", "..a") == 72 +# +# # Test missing path (defaults to root) +# assert client.json().debug("MEMORY", "doc1") == 72 +# +# # Test missing key +# assert client.json().debug("MEMORY", "non_existing_doc", "$..a") == [] @pytest.mark.redismod From c8dfe158ad0d28ad62965d5da9fb8dc860251be0 Mon Sep 17 00:00:00 2001 From: Akuli Date: Wed, 15 Dec 2021 14:26:31 +0200 Subject: [PATCH 0298/1164] Fix link in lmove docstring (#1793) --- redis/commands/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index fec3095cc5..62e3ba85c2 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1244,7 +1244,7 @@ def lmove(self, first_list, second_list, src="LEFT", dest="RIGHT"): pushing it as the first/last element on the destination list. Returns the element being popped and pushed. - For more information check https://redis.io/commands/lmov + For more information check https://redis.io/commands/lmove """ params = [first_list, second_list, src, dest] return self.execute_command("LMOVE", *params) From d17ff5913e375568eaab4c5d9a798d249aabe1e4 Mon Sep 17 00:00:00 2001 From: Ali-Akber Saifee Date: Wed, 15 Dec 2021 05:00:06 -0800 Subject: [PATCH 0299/1164] Ensure redis_connect_func is set on uds connection (#1794) --- redis/connection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/redis/connection.py b/redis/connection.py index 2001c6447b..4ba58fe495 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -552,8 +552,8 @@ def __init__( self.retry = Retry(NoBackoff(), 0) self.health_check_interval = health_check_interval self.next_health_check = 0 - self.encoder = Encoder(encoding, encoding_errors, decode_responses) self.redis_connect_func = redis_connect_func + self.encoder = Encoder(encoding, encoding_errors, decode_responses) self._sock = None self._socket_read_size = socket_read_size self.set_parser(parser_class) @@ -942,6 +942,7 @@ def __init__( health_check_interval=0, client_name=None, retry=None, + redis_connect_func=None, ): """ Initialize a new UnixDomainSocketConnection. @@ -966,6 +967,7 @@ def __init__( self.retry = Retry(NoBackoff(), 0) self.health_check_interval = health_check_interval self.next_health_check = 0 + self.redis_connect_func = redis_connect_func self.encoder = Encoder(encoding, encoding_errors, decode_responses) self._sock = None self._socket_read_size = socket_read_size From c858f8e9157dbb7915fac6d6920c31b12c061ab0 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 15 Dec 2021 17:00:39 +0200 Subject: [PATCH 0300/1164] Single sourcing the package version (#1791) --- redis/__init__.py | 12 +++++++++++- setup.py | 8 ++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/redis/__init__.py b/redis/__init__.py index 051b039d06..35044be29d 100644 --- a/redis/__init__.py +++ b/redis/__init__.py @@ -1,3 +1,10 @@ +import sys + +if sys.version_info >= (3, 8): + from importlib import metadata +else: + import importlib_metadata as metadata + from redis.client import Redis, StrictRedis from redis.cluster import RedisCluster from redis.connection import ( @@ -38,7 +45,10 @@ def int_or_str(value): return value -__version__ = "4.1.0rc2" +try: + __version__ = metadata.version("redis") +except metadata.PackageNotFoundError: + __version__ = "99.99.99" VERSION = tuple(map(int_or_str, __version__.split("."))) diff --git a/setup.py b/setup.py index b9c2e3e315..524ea845d1 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,6 @@ #!/usr/bin/env python from setuptools import find_packages, setup -import redis - setup( name="redis", description="Python client for Redis database and key-value store", @@ -10,7 +8,7 @@ long_description_content_type="text/markdown", keywords=["Redis", "key-value store", "database"], license="MIT", - version=redis.__version__, + version="4.1.0rc2", packages=find_packages( include=[ "redis", @@ -26,12 +24,10 @@ author="Redis Inc.", author_email="oss@redis.com", python_requires=">=3.6", - setup_requires=[ - "packaging>=21.3", - ], install_requires=[ "deprecated>=1.2.3", "packaging>=21.3", + 'importlib-metadata >= 1.0; python_version < "3.8"', ], classifiers=[ "Development Status :: 5 - Production/Stable", From 6c1e215bc8803a4cf72e07d15dedaa51c81d0ff2 Mon Sep 17 00:00:00 2001 From: Ashwani Gupta Date: Wed, 15 Dec 2021 20:33:27 +0530 Subject: [PATCH 0301/1164] Add CI action to install package from commit hash (#1781) (#1790) --- .github/workflows/integration.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 10ab3ed03e..bd0fb2d076 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -70,3 +70,19 @@ jobs: - name: Run installed unit tests run: | bash .github/workflows/install_and_test.sh ${{ matrix.extension }} + + install_package_from_commit: + name: Install package from commit hash + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.7'] + steps: + - uses: actions/checkout@v2 + - name: install python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: install from pip + run: | + pip install --quiet git+${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git@${GITHUB_SHA} From 82bad1686177c4c543818a8bfac35c6fdfc9ddf1 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Wed, 15 Dec 2021 16:03:45 +0100 Subject: [PATCH 0302/1164] Support SYNC and PSYNC (#1741) Co-authored-by: Chayim --- redis/commands/core.py | 25 +++++++++++++++++++++++++ redis/connection.py | 2 +- tests/test_commands.py | 12 ++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index 62e3ba85c2..835ea6125a 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -637,6 +637,31 @@ def flushdb(self, asynchronous=False, **kwargs): args.append(b"ASYNC") return self.execute_command("FLUSHDB", *args, **kwargs) + def sync(self): + """ + Initiates a replication stream from the master. + + For more information check https://redis.io/commands/sync + """ + from redis.client import NEVER_DECODE + + options = {} + options[NEVER_DECODE] = [] + return self.execute_command("SYNC", **options) + + def psync(self, replicationid, offset): + """ + Initiates a replication stream from the master. + Newer version for `sync`. + + For more information check https://redis.io/commands/sync + """ + from redis.client import NEVER_DECODE + + options = {} + options[NEVER_DECODE] = [] + return self.execute_command("PSYNC", replicationid, offset, **options) + def swapdb(self, first, second, **kwargs): """ Swap two databases diff --git a/redis/connection.py b/redis/connection.py index 4ba58fe495..6c4494b145 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -382,7 +382,7 @@ def __del__(self): except Exception: pass - def on_connect(self, connection): + def on_connect(self, connection, **kwargs): self._sock = connection._sock self._socket_timeout = connection.socket_timeout kwargs = { diff --git a/tests/test_commands.py b/tests/test_commands.py index eab9072c0d..b8dc69f9eb 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -4151,6 +4151,18 @@ def test_replicaof(self, r): assert r.replicaof("NO ONE") assert r.replicaof("NO", "ONE") + @skip_if_server_version_lt("2.8.0") + def test_sync(self, r): + r2 = redis.Redis(port=6380, decode_responses=False) + res = r2.sync() + assert b"REDIS" in res + + @skip_if_server_version_lt("2.8.0") + def test_psync(self, r): + r2 = redis.Redis(port=6380, decode_responses=False) + res = r2.psync(r2.client_id(), 1) + assert b"FULLRESYNC" in res + @pytest.mark.onlynoncluster class TestBinarySave: From a8b8f142399a62e64c3003adda2d9563eea95ef4 Mon Sep 17 00:00:00 2001 From: Paul Brown Date: Thu, 16 Dec 2021 07:35:22 +0000 Subject: [PATCH 0303/1164] close socket after server disconnect (#1797) --- redis/connection.py | 9 +++++++-- tests/test_connection.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/redis/connection.py b/redis/connection.py index 6c4494b145..1bb8eb50f9 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -717,9 +717,14 @@ def disconnect(self): self._parser.on_disconnect() if self._sock is None: return - try: - if os.getpid() == self.pid: + + if os.getpid() == self.pid: + try: self._sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + + try: self._sock.close() except OSError: pass diff --git a/tests/test_connection.py b/tests/test_connection.py index 22f1b718de..d94a815159 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -3,6 +3,7 @@ import pytest +from redis.connection import Connection from redis.exceptions import InvalidResponse from redis.utils import HIREDIS_AVAILABLE @@ -40,3 +41,36 @@ def inner(): # mod = j(modclient) # mod.set("fookey", ".", d) # assert mod.get('fookey') == d + + +class TestConnection: + def test_disconnect(self): + conn = Connection() + mock_sock = mock.Mock() + conn._sock = mock_sock + conn.disconnect() + mock_sock.shutdown.assert_called_once() + mock_sock.close.assert_called_once() + assert conn._sock is None + + def test_disconnect__shutdown_OSError(self): + """An OSError on socket shutdown will still close the socket.""" + conn = Connection() + mock_sock = mock.Mock() + conn._sock = mock_sock + conn._sock.shutdown.side_effect = OSError + conn.disconnect() + mock_sock.shutdown.assert_called_once() + mock_sock.close.assert_called_once() + assert conn._sock is None + + def test_disconnect__close_OSError(self): + """An OSError on socket close will still clear out the socket.""" + conn = Connection() + mock_sock = mock.Mock() + conn._sock = mock_sock + conn._sock.close.side_effect = OSError + conn.disconnect() + mock_sock.shutdown.assert_called_once() + mock_sock.close.assert_called_once() + assert conn._sock is None From 18c6809b761bc6755349e1d7e08e74e857ec2c65 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 16 Dec 2021 09:36:56 +0200 Subject: [PATCH 0304/1164] Support for password-encrypted SSL private keys (#1782) Adding support for SSL private keys with a password. This PR also adds support for future SSL tests. --- .github/workflows/install_and_test.sh | 2 +- .github/workflows/integration.yaml | 1 + .gitignore | 1 + CONTRIBUTING.md | 3 +- dev_requirements.txt | 1 + docker/base/Dockerfile | 1 + docker/base/Dockerfile.cluster | 3 +- docker/base/Dockerfile.sentinel | 1 + docker/base/Dockerfile.stunnel | 11 +++++ docker/stunnel/conf/redis.conf | 6 +++ docker/stunnel/create_certs.sh | 46 ++++++++++++++++++++ redis/client.py | 3 ++ redis/connection.py | 35 +++++++++++++-- tasks.py | 13 ++++++ tests/conftest.py | 17 +++++++- tests/test_ssl.py | 61 +++++++++++++++++++++++++++ tox.ini | 27 ++++++++---- 17 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 docker/base/Dockerfile.stunnel create mode 100644 docker/stunnel/conf/redis.conf create mode 100755 docker/stunnel/create_certs.sh create mode 100644 tests/test_ssl.py diff --git a/.github/workflows/install_and_test.sh b/.github/workflows/install_and_test.sh index 7a8cd672fd..33a1edb1e7 100755 --- a/.github/workflows/install_and_test.sh +++ b/.github/workflows/install_and_test.sh @@ -42,4 +42,4 @@ pip install ${PKG} pytest -m 'not onlycluster' # RedisCluster tests CLUSTER_URL="redis://localhost:16379/0" -pytest -m 'not onlynoncluster and not redismod' --redis-url=${CLUSTER_URL} +pytest -m 'not onlynoncluster and not redismod and not ssl' --redis-url=${CLUSTER_URL} diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index bd0fb2d076..e81cf339fd 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -48,6 +48,7 @@ jobs: - name: run tests run: | pip install -r dev_requirements.txt + bash docker/stunnel/create_certs.sh tox -e ${{matrix.test-type}}-${{matrix.connection-type}} - name: Upload codecov coverage uses: codecov/codecov-action@v2 diff --git a/.gitignore b/.gitignore index 08138d7c8c..96fbdd5646 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ coverage.xml .venv *.xml .coverage* +docker/stunnel/keys diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe37ff9abe..04f989a308 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,7 @@ can execute docker and its various commands. - A Redis replica node - Three sentinel Redis nodes - A multi-python docker, with your source code mounted in /data +- An stunnel docker, fronting the master Redis node The replica node, is a replica of the master node, using the [leader-follower replication](https://redis.io/topics/replication) @@ -73,7 +74,7 @@ tests as well. With the 'tests' and 'all-tests' targets, all Redis and RedisCluster tests will be run. It is possible to run only Redis client tests (with cluster mode disabled) by -using `invoke redis-tests`; similarly, RedisCluster tests can be run by using +using `invoke standalone-tests`; similarly, RedisCluster tests can be run by using `invoke cluster-tests`. Each run of tox starts and stops the various dockers required. Sometimes diff --git a/dev_requirements.txt b/dev_requirements.txt index 2a4f37762f..1d33b9875b 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -6,6 +6,7 @@ pytest==6.2.5 pytest-timeout==2.0.1 tox==3.24.4 tox-docker==3.1.0 +tox-run-before==0.1 invoke==1.6.0 pytest-cov>=3.0.0 vulture>=2.3.0 diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 60be37483b..c76d15db36 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1,3 +1,4 @@ +# produces redisfab/redis-py:6.2.6 FROM redis:6.2.6-buster CMD ["redis-server", "/redis.conf"] diff --git a/docker/base/Dockerfile.cluster b/docker/base/Dockerfile.cluster index 70e8013631..70df5babf0 100644 --- a/docker/base/Dockerfile.cluster +++ b/docker/base/Dockerfile.cluster @@ -1,3 +1,4 @@ +# produces redisfab/redis-py-cluster:6.2.6 FROM redis:6.2.6-buster COPY create_cluster.sh /create_cluster.sh @@ -5,4 +6,4 @@ RUN chmod +x /create_cluster.sh EXPOSE 16379 16380 16381 16382 16383 16384 -CMD [ "/create_cluster.sh"] \ No newline at end of file +CMD [ "/create_cluster.sh"] diff --git a/docker/base/Dockerfile.sentinel b/docker/base/Dockerfile.sentinel index 93c16a71ab..ef659e3004 100644 --- a/docker/base/Dockerfile.sentinel +++ b/docker/base/Dockerfile.sentinel @@ -1,3 +1,4 @@ +# produces redisfab/redis-py-sentinel:6.2.6 FROM redis:6.2.6-buster CMD ["redis-sentinel", "/sentinel.conf"] diff --git a/docker/base/Dockerfile.stunnel b/docker/base/Dockerfile.stunnel new file mode 100644 index 0000000000..bf4510907c --- /dev/null +++ b/docker/base/Dockerfile.stunnel @@ -0,0 +1,11 @@ +# produces redisfab/stunnel:latest +FROM ubuntu:18.04 + +RUN apt-get update -qq --fix-missing +RUN apt-get upgrade -qqy +RUN apt install -qqy stunnel +RUN mkdir -p /etc/stunnel/conf.d +RUN echo "foreground = yes\ninclude = /etc/stunnel/conf.d" > /etc/stunnel/stunnel.conf +RUN chown -R root:root /etc/stunnel/ + +CMD ["/usr/bin/stunnel"] diff --git a/docker/stunnel/conf/redis.conf b/docker/stunnel/conf/redis.conf new file mode 100644 index 0000000000..84f6d40133 --- /dev/null +++ b/docker/stunnel/conf/redis.conf @@ -0,0 +1,6 @@ +[redis] +accept = 6666 +connect = master:6379 +cert = /etc/stunnel/keys/server-cert.pem +key = /etc/stunnel/keys/server-key.pem +verify = 0 diff --git a/docker/stunnel/create_certs.sh b/docker/stunnel/create_certs.sh new file mode 100755 index 0000000000..f3bcea6f5d --- /dev/null +++ b/docker/stunnel/create_certs.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -e + +DESTDIR=`dirname "$0"`/keys +test -d ${DESTDIR} || mkdir ${DESTDIR} +cd ${DESTDIR} + +SSL_SUBJECT="/C=CA/ST=Winnipeg/L=Manitoba/O=Some Corp/OU=IT Department/CN=example.com" +which openssl &>/dev/null +if [ $? -ne 0 ]; then + echo "No openssl binary present, exiting." + exit 1 +fi + +openssl genrsa -out ca-key.pem 2048 &>/dev/null + +openssl req -new -x509 -nodes -days 365000 \ + -key ca-key.pem \ + -out ca-cert.pem \ + -subj "${SSL_SUBJECT}" &>/dev/null + +openssl req -newkey rsa:2048 -nodes -days 365000 \ + -keyout server-key.pem \ + -out server-req.pem \ + -subj "${SSL_SUBJECT}" &>/dev/null + +openssl x509 -req -days 365000 -set_serial 01 \ + -in server-req.pem \ + -out server-cert.pem \ + -CA ca-cert.pem \ + -CAkey ca-key.pem &>/dev/null + +openssl req -newkey rsa:2048 -nodes -days 365000 \ + -keyout client-key.pem \ + -out client-req.pem \ + -subj "${SSL_SUBJECT}" &>/dev/null + +openssl x509 -req -days 365000 -set_serial 01 \ + -in client-req.pem \ + -out client-cert.pem \ + -CA ca-cert.pem \ + -CAkey ca-key.pem &>/dev/null + +echo "Keys generated in ${DESTDIR}:" +ls diff --git a/redis/client.py b/redis/client.py index c02bc3a4d5..3ae133c300 100755 --- a/redis/client.py +++ b/redis/client.py @@ -873,7 +873,9 @@ def __init__( ssl_certfile=None, ssl_cert_reqs="required", ssl_ca_certs=None, + ssl_ca_path=None, ssl_check_hostname=False, + ssl_password=None, max_connections=None, single_connection_client=False, health_check_interval=0, @@ -947,6 +949,7 @@ def __init__( "ssl_cert_reqs": ssl_cert_reqs, "ssl_ca_certs": ssl_ca_certs, "ssl_check_hostname": ssl_check_hostname, + "ssl_password": ssl_password, } ) connection_pool = ConnectionPool(**kwargs) diff --git a/redis/connection.py b/redis/connection.py index 1bb8eb50f9..3fe8543ca6 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -884,6 +884,11 @@ def pack_commands(self, commands): class SSLConnection(Connection): + """Manages SSL connections to and from the Redis server(s). + This class extends the Connection class, adding SSL functionality, and making + use of ssl.SSLContext (https://docs.python.org/3/library/ssl.html#ssl.SSLContext) + """ # noqa + def __init__( self, ssl_keyfile=None, @@ -891,8 +896,24 @@ def __init__( ssl_cert_reqs="required", ssl_ca_certs=None, ssl_check_hostname=False, + ssl_ca_path=None, + ssl_password=None, **kwargs, ): + """Constructor + + Args: + ssl_keyfile: Path to an ssl private key. Defaults to None. + ssl_certfile: Path to an ssl certificate. Defaults to None. + ssl_cert_reqs: The string value for the SSLContext.verify_mode (none, optional, required). Defaults to "required". + ssl_ca_certs: The path to a file of concatenated CA certificates in PEM format. Defaults to None. + ssl_check_hostname: If set, match the hostname during the SSL handshake. Defaults to False. + ssl_ca_path: The path to a directory containing several CA certificates in PEM format. Defaults to None. + ssl_password: Password for unlocking an encrypted private key. Defaults to None. + + Raises: + RedisError + """ # noqa if not ssl_available: raise RedisError("Python wasn't built with SSL support") @@ -915,7 +936,9 @@ def __init__( ssl_cert_reqs = CERT_REQS[ssl_cert_reqs] self.cert_reqs = ssl_cert_reqs self.ca_certs = ssl_ca_certs + self.ca_path = ssl_ca_path self.check_hostname = ssl_check_hostname + self.certificate_password = ssl_password def _connect(self): "Wrap the socket with SSL support" @@ -923,10 +946,14 @@ def _connect(self): context = ssl.create_default_context() context.check_hostname = self.check_hostname context.verify_mode = self.cert_reqs - if self.certfile and self.keyfile: - context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) - if self.ca_certs: - context.load_verify_locations(self.ca_certs) + if self.certfile or self.keyfile: + context.load_cert_chain( + certfile=self.certfile, + keyfile=self.keyfile, + password=self.certificate_password, + ) + if self.ca_certs is not None or self.ca_path is not None: + context.load_verify_locations(cafile=self.ca_certs, capath=self.ca_path) return context.wrap_socket(sock, server_hostname=self.host) diff --git a/tasks.py b/tasks.py index 880e70dcf0..98ed483fb1 100644 --- a/tasks.py +++ b/tasks.py @@ -3,6 +3,11 @@ from invoke import run, task + +def _generate_keys(): + run("bash docker/stunnel/create_certs.sh") + + with open("tox.ini") as fp: lines = fp.read().split("\n") dockers = [line.split("=")[1].strip() for line in lines if line.find("name") != -1] @@ -14,6 +19,7 @@ def devenv(c): specified in the tox.ini file. """ clean(c) + _generate_keys() cmd = "tox -e devenv" for d in dockers: cmd += f" --docker-dont-stop={d}" @@ -29,6 +35,7 @@ def build_docs(c): @task def linters(c): """Run code linters""" + _generate_keys() run("tox -e linters") @@ -37,6 +44,7 @@ def all_tests(c): """Run all linters, and tests in redis-py. This assumes you have all the python versions specified in the tox.ini file. """ + _generate_keys() linters(c) tests(c) @@ -47,6 +55,7 @@ def tests(c): with and without hiredis. """ print("Starting Redis tests") + _generate_keys() run("tox -e '{standalone,cluster}'-'{plain,hiredis}'") @@ -55,6 +64,7 @@ def standalone_tests(c): """Run all Redis tests against the current python, with and without hiredis.""" print("Starting Redis tests") + _generate_keys() run("tox -e standalone-'{plain,hiredis}'") @@ -63,6 +73,7 @@ def cluster_tests(c): """Run all Redis Cluster tests against the current python, with and without hiredis.""" print("Starting RedisCluster tests") + _generate_keys() run("tox -e cluster-'{plain,hiredis}'") @@ -74,6 +85,8 @@ def clean(c): if os.path.isdir("dist"): shutil.rmtree("dist") run(f"docker rm -f {' '.join(dockers)}") + if os.path.isdir("docker/stunnel/keys"): + shutil.rmtree("docker/stunnel/keys") @task diff --git a/tests/conftest.py b/tests/conftest.py index 4b5f6cbd5c..0149166d65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,8 +14,10 @@ REDIS_INFO = {} default_redis_url = "redis://localhost:6379/9" - default_redismod_url = "redis://localhost:36379" + +# default ssl client ignores verification for the purpose of testing +default_redis_ssl_url = "rediss://localhost:6666" default_cluster_nodes = 6 @@ -36,6 +38,13 @@ def pytest_addoption(parser): " defaults to `%(default)s`", ) + parser.addoption( + "--redis-ssl-url", + default=default_redis_ssl_url, + action="store", + help="Redis SSL connection string," " defaults to `%(default)s`", + ) + parser.addoption( "--redis-cluster-nodes", default=default_cluster_nodes, @@ -248,6 +257,12 @@ def r2(request): yield client +@pytest.fixture() +def sslclient(request): + with _get_client(redis.Redis, request, ssl=True) as client: + yield client + + def _gen_cluster_mock_resp(r, response): connection = Mock() connection.retry = Retry(NoBackoff(), 0) diff --git a/tests/test_ssl.py b/tests/test_ssl.py new file mode 100644 index 0000000000..70f9e58b76 --- /dev/null +++ b/tests/test_ssl.py @@ -0,0 +1,61 @@ +import os +from urllib.parse import urlparse + +import pytest + +import redis +from redis.exceptions import ConnectionError + + +@pytest.mark.ssl +class TestSSL: + """Tests for SSL connections + + This relies on the --redis-ssl-url purely for rebuilding the client + and connecting to the appropriate port. + """ + + ROOT = os.path.join(os.path.dirname(__file__), "..") + CERT_DIR = os.path.abspath(os.path.join(ROOT, "docker", "stunnel", "keys")) + if not os.path.isdir(CERT_DIR): # github actions package validation case + CERT_DIR = os.path.abspath( + os.path.join(ROOT, "..", "docker", "stunnel", "keys") + ) + if not os.path.isdir(CERT_DIR): + raise IOError(f"No SSL certificates found. They should be in {CERT_DIR}") + + def test_ssl_with_invalid_cert(self, request): + ssl_url = request.config.option.redis_ssl_url + sslclient = redis.from_url(ssl_url) + with pytest.raises(ConnectionError) as e: + sslclient.ping() + assert "SSL: CERTIFICATE_VERIFY_FAILED" in str(e) + + def test_ssl_connection(self, request): + ssl_url = request.config.option.redis_ssl_url + p = urlparse(ssl_url)[1].split(":") + r = redis.Redis(host=p[0], port=p[1], ssl=True, ssl_cert_reqs="none") + assert r.ping() + + def test_ssl_connection_without_ssl(self, request): + ssl_url = request.config.option.redis_ssl_url + p = urlparse(ssl_url)[1].split(":") + r = redis.Redis(host=p[0], port=p[1], ssl=False) + + with pytest.raises(ConnectionError) as e: + r.ping() + assert "Connection closed by server" in str(e) + + def test_validating_self_signed_certificate(self, request): + ssl_url = request.config.option.redis_ssl_url + p = urlparse(ssl_url)[1].split(":") + r = redis.Redis( + host=p[0], + port=p[1], + ssl=True, + ssl_certfile=os.path.join(self.CERT_DIR, "server-cert.pem"), + ssl_keyfile=os.path.join(self.CERT_DIR, "server-key.pem"), + ssl_cert_reqs="required", + ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"), + ) + assert r.ping() diff --git a/tox.ini b/tox.ini index 32d1680a2a..b574d17b57 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ markers = pipeline: pipeline tests onlycluster: marks tests to be run only with cluster mode redis onlynoncluster: marks tests to be run only with standalone redis + ssl: marker for only the ssl tests [tox] minversion = 3.2.0 @@ -31,6 +32,7 @@ healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(sock volumes = bind:rw:{toxinidir}/docker/replica/redis.conf:/redis.conf + [docker:sentinel_1] name = sentinel_1 image = redisfab/redis-py-sentinel:6.2.6-buster @@ -91,6 +93,18 @@ healtcheck_cmd = python -c "import socket;print(True) if all([0 == socket.socket volumes = bind:rw:{toxinidir}/docker/cluster/redis.conf:/redis.conf +[docker:stunnel] +name = stunnel +image = redisfab/stunnel:latest +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',6666)) else False" +links = + master:master +ports = + 6666:6666/tcp +volumes = + bind:ro:{toxinidir}/docker/stunnel/conf:/etc/stunnel/conf.d + bind:ro:{toxinidir}/docker/stunnel/keys:/etc/stunnel/keys + [isort] profile = black multi_line_output = 3 @@ -107,10 +121,12 @@ docker = sentinel_3 redis_cluster redismod + stunnel extras = hiredis: hiredis setenv = CLUSTER_URL = "redis://localhost:16379/0" +run_before = {toxinidir}/docker/stunnel/create_certs.sh commands = standalone: pytest --cov=./ --cov-report=xml:coverage_redis.xml -W always -m 'not onlycluster' {posargs} cluster: pytest --cov=./ --cov-report=xml:coverage_cluster.xml -W always -m 'not onlynoncluster and not redismod' --redis-url={env:CLUSTER_URL:} {posargs} @@ -119,16 +135,9 @@ commands = skipsdist = true skip_install = true deps = -r {toxinidir}/dev_requirements.txt -docker = - master - replica - sentinel_1 - sentinel_2 - sentinel_3 - redis_cluster - redismod - lots-of-pythons +docker = {[testenv]docker} commands = /usr/bin/echo +run_before = {[testenv]run_before} [testenv:linters] deps_files = dev_requirements.txt From 4831034c0be47fd0979ce43b186173bcb6b2011d Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 19 Dec 2021 10:39:37 +0200 Subject: [PATCH 0305/1164] Allow ssl_ca_path with rediss:// urls (#1814) --- redis/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/redis/client.py b/redis/client.py index 3ae133c300..ae4fae2ace 100755 --- a/redis/client.py +++ b/redis/client.py @@ -950,6 +950,7 @@ def __init__( "ssl_ca_certs": ssl_ca_certs, "ssl_check_hostname": ssl_check_hostname, "ssl_password": ssl_password, + "ssl_ca_path": ssl_ca_path, } ) connection_pool = ConnectionPool(**kwargs) From aa0c80906a58ce4f79b3251e095d1fe0b816cf15 Mon Sep 17 00:00:00 2001 From: dvora-h Date: Sun, 19 Dec 2021 17:38:57 +0200 Subject: [PATCH 0306/1164] add set_file and set_path --- redis/commands/json/commands.py | 39 +++++++++++++++++++++++++++++++++ tests/test_json.py | 12 ++++++++++ 2 files changed, 51 insertions(+) diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py index e7f07b612f..92b018c572 100644 --- a/redis/commands/json/commands.py +++ b/redis/commands/json/commands.py @@ -4,6 +4,8 @@ from .decoders import decode_dict_keys from .path import Path +from json import loads, JSONDecodeError +import os class JSONCommands: @@ -213,6 +215,43 @@ def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): pieces.append("XX") return self.execute_command("JSON.SET", *pieces) + def set_file(self, name, path, file_name, nx=False, xx=False, decode_keys=False): + """ + Set the JSON value at key ``name`` under the ``path`` to the contents of the json file ``file_name``. + + ``nx`` if set to True, set ``value`` only if it does not exist. + ``xx`` if set to True, set ``value`` only if it exists. + ``decode_keys`` If set to True, the keys of ``obj`` will be decoded + with utf-8. + + """ + try: + file_content = loads(file_name) + except JSONDecodeError: + raise JSONDecodeError("Inappropriate file type, set_file() requires json file") + + return self.set(name, path, file_content, nx, xx, decode_keys) + + + def set_path(self, json_path, root_directory , nx=False, xx=False, decode_keys=False): + """ + + """ + set_files_result = {} + for root, dirs, files in os.walk(root_directory): + for file in files: + try: + file_name = file.rsplit('.')[0] + file_path = os.path.join(root, file) + self.set_file(file_name, json_path, file_path, nx, xx, decode_keys) + set_files_result[os.path.join(root, file)] = True + except JSONDecodeError: + set_files_result[os.path.join(root, file)] = False + + return set_files_result + + + def strlen(self, name, path=None): """Return the length of the string JSON value under ``path`` at key ``name``. diff --git a/tests/test_json.py b/tests/test_json.py index a99547dd04..74b00ea087 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1392,3 +1392,15 @@ def test_custom_decoder(client): assert client.exists("foo") == 0 assert not isinstance(cj.__encoder__, json.JSONEncoder) assert not isinstance(cj.__decoder__, json.JSONDecoder) + + +@pytest.mark.redismod +def test_set_file(client): + import tempfile + import json + + jsonfile = tempfile.NamedTemporaryFile(suffix=".json") + with open(jsonfile.name, 'w+') as fp: + fp.write(json.dumps({"hello": "world"})) + + assert client.json().set("test", Path.rootPath(), jsonfile.name) \ No newline at end of file From 58d383f01c0859c3ff76a2a9695cf7f1730d6075 Mon Sep 17 00:00:00 2001 From: dvora-h Date: Mon, 20 Dec 2021 12:16:04 +0200 Subject: [PATCH 0307/1164] fixing tests and lint --- redis/commands/json/commands.py | 33 +++++++++++++++++++-------------- tests/test_json.py | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py index 92b018c572..626f2c1898 100644 --- a/redis/commands/json/commands.py +++ b/redis/commands/json/commands.py @@ -1,11 +1,12 @@ +import os +from json import JSONDecodeError, loads + from deprecated import deprecated from redis.exceptions import DataError from .decoders import decode_dict_keys from .path import Path -from json import loads, JSONDecodeError -import os class JSONCommands: @@ -217,7 +218,8 @@ def set(self, name, path, obj, nx=False, xx=False, decode_keys=False): def set_file(self, name, path, file_name, nx=False, xx=False, decode_keys=False): """ - Set the JSON value at key ``name`` under the ``path`` to the contents of the json file ``file_name``. + Set the JSON value at key ``name`` under the ``path`` to the content + of the json file ``file_name``. ``nx`` if set to True, set ``value`` only if it does not exist. ``xx`` if set to True, set ``value`` only if it exists. @@ -225,23 +227,28 @@ def set_file(self, name, path, file_name, nx=False, xx=False, decode_keys=False) with utf-8. """ - try: - file_content = loads(file_name) - except JSONDecodeError: - raise JSONDecodeError("Inappropriate file type, set_file() requires json file") - + + with open(file_name, "r") as fp: + file_content = loads(fp.read()) + return self.set(name, path, file_content, nx, xx, decode_keys) + def set_path(self, json_path, root_folder, nx=False, xx=False, decode_keys=False): + """ + Iterate over ``root_folder`` and set each JSON file to a value + under ``json_path`` with the file name as the key. - def set_path(self, json_path, root_directory , nx=False, xx=False, decode_keys=False): - """ + ``nx`` if set to True, set ``value`` only if it does not exist. + ``xx`` if set to True, set ``value`` only if it exists. + ``decode_keys`` If set to True, the keys of ``obj`` will be decoded + with utf-8. """ set_files_result = {} - for root, dirs, files in os.walk(root_directory): + for root, dirs, files in os.walk(root_folder): for file in files: try: - file_name = file.rsplit('.')[0] + file_name = os.path.join(root, file).rsplit(".")[0] file_path = os.path.join(root, file) self.set_file(file_name, json_path, file_path, nx, xx, decode_keys) set_files_result[os.path.join(root, file)] = True @@ -250,8 +257,6 @@ def set_path(self, json_path, root_directory , nx=False, xx=False, decode_keys=F return set_files_result - - def strlen(self, name, path=None): """Return the length of the string JSON value under ``path`` at key ``name``. diff --git a/tests/test_json.py b/tests/test_json.py index 74b00ea087..9faf0aebce 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1396,11 +1396,38 @@ def test_custom_decoder(client): @pytest.mark.redismod def test_set_file(client): - import tempfile import json + import tempfile + obj = {"hello": "world"} jsonfile = tempfile.NamedTemporaryFile(suffix=".json") - with open(jsonfile.name, 'w+') as fp: + with open(jsonfile.name, "w+") as fp: + fp.write(json.dumps(obj)) + + nojsonfile = tempfile.NamedTemporaryFile() + nojsonfile.write(b"Hello World") + + assert client.json().set_file("test", Path.rootPath(), jsonfile.name) + assert client.json().get("test") == obj + with pytest.raises(json.JSONDecodeError): + client.json().set_file("test2", Path.rootPath(), nojsonfile.name) + + +@pytest.mark.redismod +def test_set_path(client): + import json + import os + import tempfile + + root = tempfile.mkdtemp() + sub = tempfile.mkdtemp(dir=root) + ospointer, jsonfile = tempfile.mkstemp(suffix=".json", dir=sub) + ospointer2, nojsonfile = tempfile.mkstemp(dir=root) + + with open(jsonfile, "w+") as fp: fp.write(json.dumps({"hello": "world"})) + open(nojsonfile, "a+").write("hello") - assert client.json().set("test", Path.rootPath(), jsonfile.name) \ No newline at end of file + result = {"/private" + jsonfile: True, "/private" + nojsonfile: False} + assert client.json().set_path(Path.rootPath(), os.path.realpath(root)) == result + assert client.json().get("/private" + jsonfile.rsplit(".")[0]) == {"hello": "world"} From b58cd9015a33aa39f04eb417e9e4496e2961efad Mon Sep 17 00:00:00 2001 From: dvora-h Date: Mon, 20 Dec 2021 16:56:48 +0200 Subject: [PATCH 0308/1164] fix test_set_path --- tests/test_json.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_json.py b/tests/test_json.py index 9faf0aebce..8bd1d77718 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1416,18 +1416,18 @@ def test_set_file(client): @pytest.mark.redismod def test_set_path(client): import json - import os import tempfile root = tempfile.mkdtemp() sub = tempfile.mkdtemp(dir=root) - ospointer, jsonfile = tempfile.mkstemp(suffix=".json", dir=sub) - ospointer2, nojsonfile = tempfile.mkstemp(dir=root) + jsonfile = tempfile.mktemp(suffix=".json", dir=sub) + nojsonfile = tempfile.mktemp(dir=root) with open(jsonfile, "w+") as fp: fp.write(json.dumps({"hello": "world"})) open(nojsonfile, "a+").write("hello") - result = {"/private" + jsonfile: True, "/private" + nojsonfile: False} - assert client.json().set_path(Path.rootPath(), os.path.realpath(root)) == result - assert client.json().get("/private" + jsonfile.rsplit(".")[0]) == {"hello": "world"} + result = {jsonfile: True, nojsonfile: False} + print(result) + assert client.json().set_path(Path.rootPath(), root) == result + assert client.json().get(jsonfile.rsplit(".")[0]) == {"hello": "world"} From 1757d97e0483bd27917124508ce52105b57d2994 Mon Sep 17 00:00:00 2001 From: dvora-h Date: Tue, 21 Dec 2021 10:38:58 +0200 Subject: [PATCH 0309/1164] fixing PR comments --- redis/commands/json/commands.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/redis/commands/json/commands.py b/redis/commands/json/commands.py index 626f2c1898..a132b8ee8d 100644 --- a/redis/commands/json/commands.py +++ b/redis/commands/json/commands.py @@ -231,7 +231,7 @@ def set_file(self, name, path, file_name, nx=False, xx=False, decode_keys=False) with open(file_name, "r") as fp: file_content = loads(fp.read()) - return self.set(name, path, file_content, nx, xx, decode_keys) + return self.set(name, path, file_content, nx=nx, xx=xx, decode_keys=decode_keys) def set_path(self, json_path, root_folder, nx=False, xx=False, decode_keys=False): """ @@ -247,13 +247,20 @@ def set_path(self, json_path, root_folder, nx=False, xx=False, decode_keys=False set_files_result = {} for root, dirs, files in os.walk(root_folder): for file in files: + file_path = os.path.join(root, file) try: - file_name = os.path.join(root, file).rsplit(".")[0] - file_path = os.path.join(root, file) - self.set_file(file_name, json_path, file_path, nx, xx, decode_keys) - set_files_result[os.path.join(root, file)] = True + file_name = file_path.rsplit(".")[0] + self.set_file( + file_name, + json_path, + file_path, + nx=nx, + xx=xx, + decode_keys=decode_keys, + ) + set_files_result[file_path] = True except JSONDecodeError: - set_files_result[os.path.join(root, file)] = False + set_files_result[file_path] = False return set_files_result From 01fedafedbdd464600f6fbcf7d21a93333b24f7f Mon Sep 17 00:00:00 2001 From: yanivhershkovich <48108541+yanivhershkovich@users.noreply.github.com> Date: Tue, 21 Dec 2021 22:30:22 -0800 Subject: [PATCH 0310/1164] Update redismodules.rst (#1822) Updated links to RediSearch, RedisGraph and RedisBloom sites as they were all pointing to RedisJSON instead. --- docs/redismodules.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/redismodules.rst b/docs/redismodules.rst index 0cb8c49660..53a5008133 100644 --- a/docs/redismodules.rst +++ b/docs/redismodules.rst @@ -44,7 +44,7 @@ These are the commands for interacting with the `RedisJSON module `_. Below is a brief example, as well as documentation on the commands themselves. +These are the commands for interacting with the `RediSearch module `_. Below is a brief example, as well as documentation on the commands themselves. **Create a search index, and display its information** @@ -64,7 +64,7 @@ These are the commands for interacting with the `RediSearch module `_. Below is a brief example, as well as documentation on the commands themselves. +These are the commands for interacting with the `RedisGraph module `_. Below is a brief example, as well as documentation on the commands themselves. **Create a graph, adding two nodes** @@ -97,7 +97,7 @@ These are the commands for interacting with the `RedisGraph module `_. Below is a brief example, as well as documentation on the commands themselves. +These are the commands for interacting with the `RedisBloom module `_. Below is a brief example, as well as documentation on the commands themselves. **Create and add to a bloom filter** From e0d3ba5bd73406f80cf89609be45a0006a5382d8 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Wed, 22 Dec 2021 10:01:17 +0200 Subject: [PATCH 0311/1164] Fixed MovedError, and stopped iterating through startup nodes when slots are fully covered (#1819) --- redis/cluster.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/redis/cluster.py b/redis/cluster.py index b1adeb7341..0c2fc715d7 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -996,9 +996,11 @@ def _execute_command(self, target_node, *args, **kwargs): self.reinitialize_counter += 1 if self._should_reinitialized(): self.nodes_manager.initialize() + # Reset the counter + self.reinitialize_counter = 0 else: self.nodes_manager.update_moved_exception(e) - moved = True + moved = True except TryAgainError: log.exception("TryAgainError") @@ -1320,6 +1322,7 @@ def initialize(self): tmp_slots = {} disagreements = [] startup_nodes_reachable = False + fully_covered = False kwargs = self.connection_kwargs for startup_node in self.startup_nodes.values(): try: @@ -1431,6 +1434,12 @@ def initialize(self): f'slots cache: {", ".join(disagreements)}' ) + fully_covered = self.check_slots_coverage(tmp_slots) + if fully_covered: + # Don't need to continue to the next startup node if all + # slots are covered + break + if not startup_nodes_reachable: raise RedisClusterException( "Redis Cluster cannot be connected. Please provide at least " @@ -1440,7 +1449,6 @@ def initialize(self): # Create Redis connections to all nodes self.create_redis_connections(list(tmp_nodes_cache.values())) - fully_covered = self.check_slots_coverage(tmp_slots) # Check if the slots are not fully covered if not fully_covered and self._require_full_coverage: # Despite the requirement that the slots be covered, there @@ -1478,6 +1486,8 @@ def initialize(self): self.default_node = self.get_nodes_by_server_type(PRIMARY)[0] # Populate the startup nodes with all discovered nodes self.populate_startup_nodes(self.nodes_cache.values()) + # If initialize was called after a MovedError, clear it + self._moved_exception = None def close(self): self.default_node = None From f99744b9049be78bbbe558fbc8ac62d94ac62a4d Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Wed, 22 Dec 2021 10:51:53 +0100 Subject: [PATCH 0312/1164] Support WRITE in CLIENT PAUSE (#1549) Co-authored-by: Chayim I. Kirshen --- redis/commands/core.py | 18 +++++++++++++++--- tests/test_commands.py | 7 +++++++ tests/test_json.py | 1 - 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index 835ea6125a..6a8cd159ba 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -510,16 +510,28 @@ def client_unblock(self, client_id, error=False, **kwargs): args.append(b"ERROR") return self.execute_command(*args, **kwargs) - def client_pause(self, timeout, **kwargs): + def client_pause(self, timeout, all=True, **kwargs): """ Suspend all the Redis clients for the specified amount of time :param timeout: milliseconds to pause clients For more information check https://redis.io/commands/client-pause - """ + :param all: If true (default) all client commands are blocked. + otherwise, clients are only blocked if they attempt to execute + a write command. + For the WRITE mode, some commands have special behavior: + EVAL/EVALSHA: Will block client for all scripts. + PUBLISH: Will block client. + PFCOUNT: Will block client. + WAIT: Acknowledgments will be delayed, so this command will + appear blocked. + """ + args = ["CLIENT PAUSE", str(timeout)] if not isinstance(timeout, int): raise DataError("CLIENT PAUSE timeout must be an integer") - return self.execute_command("CLIENT PAUSE", str(timeout), **kwargs) + if not all: + args.append("WRITE") + return self.execute_command(*args, **kwargs) def client_unpause(self, **kwargs): """ diff --git a/tests/test_commands.py b/tests/test_commands.py index b8dc69f9eb..510ec7dbcf 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -557,6 +557,13 @@ def test_client_pause(self, r): with pytest.raises(exceptions.RedisError): r.client_pause(timeout="not an integer") + @skip_if_server_version_lt("6.2.0") + def test_client_pause_all(self, r, r2): + assert r.client_pause(1, all=False) + assert r2.set("foo", "bar") + assert r2.get("foo") == b"bar" + assert r.get("foo") == b"bar" + @pytest.mark.onlynoncluster @skip_if_server_version_lt("6.2.0") @skip_if_redis_enterprise() diff --git a/tests/test_json.py b/tests/test_json.py index 8bd1d77718..6980e67398 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1428,6 +1428,5 @@ def test_set_path(client): open(nojsonfile, "a+").write("hello") result = {jsonfile: True, nojsonfile: False} - print(result) assert client.json().set_path(Path.rootPath(), root) == result assert client.json().get(jsonfile.rsplit(".")[0]) == {"hello": "world"} From 1bcdf2d7835fad8e7a782fccea9cbbec745bcf24 Mon Sep 17 00:00:00 2001 From: dvora-h <67596500+dvora-h@users.noreply.github.com> Date: Wed, 22 Dec 2021 11:52:03 +0200 Subject: [PATCH 0313/1164] Fixing exception in listen (#1823) --- redis/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/redis/client.py b/redis/client.py index ae4fae2ace..16ffbb01fd 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1530,6 +1530,8 @@ def handle_message(self, response, ignore_subscribe_messages=False): with a message handler, the handler is invoked instead of a parsed message being returned. """ + if response is None: + return None message_type = str_if_bytes(response[0]) if message_type == "pmessage": message = { From 139bcbb07e7bc45a4762c7bcb17f4e80235ef8f8 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Wed, 22 Dec 2021 10:52:13 +0100 Subject: [PATCH 0314/1164] Support CLIENT TRACKING (#1612) Co-authored-by: Chayim I. Kirshen --- redis/commands/core.py | 98 ++++++++++++++++++++++++++++++++++++++++++ tests/test_commands.py | 22 ++++++++++ 2 files changed, 120 insertions(+) diff --git a/redis/commands/core.py b/redis/commands/core.py index 6a8cd159ba..2dd7c106d9 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -479,6 +479,104 @@ def client_id(self, **kwargs): """ return self.execute_command("CLIENT ID", **kwargs) + def client_tracking_on( + self, + clientid=None, + prefix=[], + bcast=False, + optin=False, + optout=False, + noloop=False, + ): + """ + Turn on the tracking mode. + For more information about the options look at client_tracking func. + + See https://redis.io/commands/client-tracking + """ + return self.client_tracking( + True, clientid, prefix, bcast, optin, optout, noloop + ) + + def client_tracking_off( + self, + clientid=None, + prefix=[], + bcast=False, + optin=False, + optout=False, + noloop=False, + ): + """ + Turn off the tracking mode. + For more information about the options look at client_tracking func. + + See https://redis.io/commands/client-tracking + """ + return self.client_tracking( + False, clientid, prefix, bcast, optin, optout, noloop + ) + + def client_tracking( + self, + on=True, + clientid=None, + prefix=[], + bcast=False, + optin=False, + optout=False, + noloop=False, + **kwargs, + ): + """ + Enables the tracking feature of the Redis server, that is used + for server assisted client side caching. + + ``on`` indicate for tracking on or tracking off. The dafualt is on. + + ``clientid`` send invalidation messages to the connection with + the specified ID. + + ``bcast`` enable tracking in broadcasting mode. In this mode + invalidation messages are reported for all the prefixes + specified, regardless of the keys requested by the connection. + + ``optin`` when broadcasting is NOT active, normally don't track + keys in read only commands, unless they are called immediately + after a CLIENT CACHING yes command. + + ``optout`` when broadcasting is NOT active, normally track keys in + read only commands, unless they are called immediately after a + CLIENT CACHING no command. + + ``noloop`` don't send notifications about keys modified by this + connection itself. + + ``prefix`` for broadcasting, register a given key prefix, so that + notifications will be provided only for keys starting with this string. + + See https://redis.io/commands/client-tracking + """ + + if len(prefix) != 0 and bcast is False: + raise DataError("Prefix can only be used with bcast") + + pieces = ["ON"] if on else ["OFF"] + if clientid is not None: + pieces.extend(["REDIRECT", clientid]) + for p in prefix: + pieces.extend(["PREFIX", p]) + if bcast: + pieces.append("BCAST") + if optin: + pieces.append("OPTIN") + if optout: + pieces.append("OPTOUT") + if noloop: + pieces.append("NOLOOP") + + return self.execute_command("CLIENT TRACKING", *pieces) + def client_trackinginfo(self, **kwargs): """ Returns the information about the current client connection's diff --git a/tests/test_commands.py b/tests/test_commands.py index 510ec7dbcf..f91804328e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -396,6 +396,28 @@ def test_client_trackinginfo(self, r): assert len(res) > 2 assert "prefixes" in res + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("6.0.0") + def test_client_tracking(self, r, r2): + + # simple case + assert r.client_tracking_on() + assert r.client_tracking_off() + + # id based + client_id = r.client_id() + assert r.client_tracking_on(client_id) + assert r.client_tracking_off(client_id) + + # id exists + client_id = r2.client_id() + assert r.client_tracking_on(client_id) + assert r2.client_tracking_off(client_id) + + # now with some prefixes + with pytest.raises(exceptions.DataError): + assert r.client_tracking_on(prefix=["foo", "bar", "blee"]) + @pytest.mark.onlynoncluster @skip_if_server_version_lt("5.0.0") def test_client_unblock(self, r): From 989d06d0832c2cccdb5b74f1b2afab2b2441fc79 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 22 Dec 2021 11:52:25 +0200 Subject: [PATCH 0315/1164] Support for RESET command since Redis 6.2.0 (#1824) --- redis/client.py | 1 + redis/commands/core.py | 7 +++++++ tests/test_commands.py | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/redis/client.py b/redis/client.py index 16ffbb01fd..c7aa17bf82 100755 --- a/redis/client.py +++ b/redis/client.py @@ -768,6 +768,7 @@ class Redis(RedisModuleCommands, CoreCommands, SentinelCommands): "STRALGO": parse_stralgo, "PUBSUB NUMSUB": parse_pubsub_numsub, "RANDOMKEY": lambda r: r and r or None, + "RESET": str_if_bytes, "SCAN": parse_scan, "SCRIPT EXISTS": lambda r: list(map(bool, r)), "SCRIPT FLUSH": bool_ok, diff --git a/redis/commands/core.py b/redis/commands/core.py index 2dd7c106d9..0823315a01 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -817,6 +817,13 @@ def lolwut(self, *version_numbers, **kwargs): else: return self.execute_command("LOLWUT", **kwargs) + def reset(self): + """Perform a full reset on the connection's server side contenxt. + + See: https://redis.io/commands/reset + """ + return self.execute_command("RESET") + def migrate( self, host, diff --git a/tests/test_commands.py b/tests/test_commands.py index f91804328e..09121617cb 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -669,6 +669,11 @@ def test_lolwut(self, r): lolwut = r.lolwut(5, 6, 7, 8).decode("utf-8") assert "Redis ver." in lolwut + @pytest.mark.onlynoncluster + @skip_if_server_version_lt("6.2.0") + def test_reset(self, r): + assert r.reset() == "RESET" + def test_object(self, r): r["a"] = "foo" assert isinstance(r.object("refcount", "a"), int) From 940d9fc428c3dbe320af003befabe812a8d8537b Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 23 Dec 2021 09:33:02 +0200 Subject: [PATCH 0316/1164] Removing lots-of-pythons container (#1827) --- CONTRIBUTING.md | 16 +++++++--------- tox.ini | 6 ------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04f989a308..0f9ca0b113 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Here's how to get started with your code contribution: 1. Create your own fork of redis-py 2. Do the changes in your fork -3. +3. *Create a virtualenv and install the development dependencies from the dev_requirements.txt file:* a. python -m venv .venv @@ -69,12 +69,12 @@ configuration](https://redis.io/topics/sentinel). ## Testing -Call `invoke tests` to run all tests, or `invoke all-tests` to run linters -tests as well. With the 'tests' and 'all-tests' targets, all Redis and -RedisCluster tests will be run. +Call `invoke tests` to run all tests, or `invoke all-tests` to run linters +tests as well. With the 'tests' and 'all-tests' targets, all Redis and +RedisCluster tests will be run. -It is possible to run only Redis client tests (with cluster mode disabled) by -using `invoke standalone-tests`; similarly, RedisCluster tests can be run by using +It is possible to run only Redis client tests (with cluster mode disabled) by +using `invoke standalone-tests`; similarly, RedisCluster tests can be run by using `invoke cluster-tests`. Each run of tox starts and stops the various dockers required. Sometimes @@ -85,9 +85,7 @@ tests against multiple versions of python. Feel free to test your changes against all the python versions supported, as declared by the tox.ini file (eg: tox -e py39). If you have the various python versions on your desktop, you can run *tox* by itself, to test all supported -versions. Alternatively, as your source code is mounted in the -**lots-of-pythons** docker, you can start exploring from there, with all -supported python versions! +versions. ### Docker Tips diff --git a/tox.ini b/tox.ini index b574d17b57..bceab0bf15 100644 --- a/tox.ini +++ b/tox.ini @@ -73,12 +73,6 @@ ports = 36379:6379/tcp healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',36379)) else False" -[docker:lots-of-pythons] -name = lots-of-pythons -image = redisfab/lots-of-pythons:latest -volumes = - bind:rw:{toxinidir}:/data - [docker:redis_cluster] name = redis_cluster image = redisfab/redis-py-cluster:6.2.6-buster From ddc51c4ace0caa0787715801b9df42e65c790d46 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Thu, 23 Dec 2021 11:42:30 +0200 Subject: [PATCH 0317/1164] Support for specifying error types with retry (#1817) --- redis/client.py | 21 +++++--- redis/connection.py | 27 ++++++++-- redis/retry.py | 8 +++ tests/test_retry.py | 125 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 169 insertions(+), 12 deletions(-) diff --git a/redis/client.py b/redis/client.py index c7aa17bf82..0236f20a32 100755 --- a/redis/client.py +++ b/redis/client.py @@ -869,6 +869,7 @@ def __init__( errors=None, decode_responses=False, retry_on_timeout=False, + retry_on_error=[], ssl=False, ssl_keyfile=None, ssl_certfile=None, @@ -887,8 +888,10 @@ def __init__( ): """ Initialize a new Redis client. - To specify a retry policy, first set `retry_on_timeout` to `True` - then set `retry` to a valid `Retry` object + To specify a retry policy for specific errors, first set + `retry_on_error` to a list of the error/s to retry on, then set + `retry` to a valid `Retry` object. + To retry on TimeoutError, `retry_on_timeout` can also be set to `True`. """ if not connection_pool: if charset is not None: @@ -905,7 +908,8 @@ def __init__( ) ) encoding_errors = errors - + if retry_on_timeout is True: + retry_on_error.append(TimeoutError) kwargs = { "db": db, "username": username, @@ -914,7 +918,7 @@ def __init__( "encoding": encoding, "encoding_errors": encoding_errors, "decode_responses": decode_responses, - "retry_on_timeout": retry_on_timeout, + "retry_on_error": retry_on_error, "retry": copy.deepcopy(retry), "max_connections": max_connections, "health_check_interval": health_check_interval, @@ -1146,11 +1150,14 @@ def _send_command_parse_response(self, conn, command_name, *args, **options): def _disconnect_raise(self, conn, error): """ Close the connection and raise an exception - if retry_on_timeout is not set or the error - is not a TimeoutError + if retry_on_error is not set or the error + is not one of the specified error types """ conn.disconnect() - if not (conn.retry_on_timeout and isinstance(error, TimeoutError)): + if ( + conn.retry_on_error is None + or isinstance(error, tuple(conn.retry_on_error)) is False + ): raise error # COMMAND EXECUTION AND PROTOCOL PARSING diff --git a/redis/connection.py b/redis/connection.py index 3fe8543ca6..a349a0f23b 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -513,6 +513,7 @@ def __init__( socket_keepalive_options=None, socket_type=0, retry_on_timeout=False, + retry_on_error=[], encoding="utf-8", encoding_errors="strict", decode_responses=False, @@ -526,8 +527,10 @@ def __init__( ): """ Initialize a new Connection. - To specify a retry policy, first set `retry_on_timeout` to `True` - then set `retry` to a valid `Retry` object + To specify a retry policy for specific errors, first set + `retry_on_error` to a list of the error/s to retry on, then set + `retry` to a valid `Retry` object. + To retry on TimeoutError, `retry_on_timeout` can also be set to `True`. """ self.pid = os.getpid() self.host = host @@ -543,11 +546,17 @@ def __init__( self.socket_type = socket_type self.retry_on_timeout = retry_on_timeout if retry_on_timeout: + # Add TimeoutError to the errors list to retry on + retry_on_error.append(TimeoutError) + self.retry_on_error = retry_on_error + if retry_on_error: if retry is None: self.retry = Retry(NoBackoff(), 1) else: # deep-copy the Retry object as it is mutable self.retry = copy.deepcopy(retry) + # Update the retry's supported errors with the specified errors + self.retry.update_supported_erros(retry_on_error) else: self.retry = Retry(NoBackoff(), 0) self.health_check_interval = health_check_interval @@ -969,6 +978,7 @@ def __init__( encoding_errors="strict", decode_responses=False, retry_on_timeout=False, + retry_on_error=[], parser_class=DefaultParser, socket_read_size=65536, health_check_interval=0, @@ -978,8 +988,10 @@ def __init__( ): """ Initialize a new UnixDomainSocketConnection. - To specify a retry policy, first set `retry_on_timeout` to `True` - then set `retry` to a valid `Retry` object + To specify a retry policy for specific errors, first set + `retry_on_error` to a list of the error/s to retry on, then set + `retry` to a valid `Retry` object. + To retry on TimeoutError, `retry_on_timeout` can also be set to `True`. """ self.pid = os.getpid() self.path = path @@ -990,11 +1002,17 @@ def __init__( self.socket_timeout = socket_timeout self.retry_on_timeout = retry_on_timeout if retry_on_timeout: + # Add TimeoutError to the errors list to retry on + retry_on_error.append(TimeoutError) + self.retry_on_error = retry_on_error + if self.retry_on_error: if retry is None: self.retry = Retry(NoBackoff(), 1) else: # deep-copy the Retry object as it is mutable self.retry = copy.deepcopy(retry) + # Update the retry's supported errors with the specified errors + self.retry.update_supported_erros(retry_on_error) else: self.retry = Retry(NoBackoff(), 0) self.health_check_interval = health_check_interval @@ -1052,6 +1070,7 @@ def to_bool(value): "socket_connect_timeout": float, "socket_keepalive": to_bool, "retry_on_timeout": to_bool, + "retry_on_error": list, "max_connections": int, "health_check_interval": int, "ssl_check_hostname": to_bool, diff --git a/redis/retry.py b/redis/retry.py index 75504c77e7..6147fbd9f9 100644 --- a/redis/retry.py +++ b/redis/retry.py @@ -19,6 +19,14 @@ def __init__( self._retries = retries self._supported_errors = supported_errors + def update_supported_erros(self, specified_errors: list): + """ + Updates the supported errors with the specified error types + """ + self._supported_errors = tuple( + set(self._supported_errors + tuple(specified_errors)) + ) + def call_with_retry(self, do, fail): """ Execute an operation that might fail and returns its result, or diff --git a/tests/test_retry.py b/tests/test_retry.py index c4650bc650..0094787247 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -1,10 +1,20 @@ +from unittest.mock import patch + import pytest from redis.backoff import NoBackoff +from redis.client import Redis from redis.connection import Connection, UnixDomainSocketConnection -from redis.exceptions import ConnectionError +from redis.exceptions import ( + BusyLoadingError, + ConnectionError, + ReadOnlyError, + TimeoutError, +) from redis.retry import Retry +from .conftest import _get_client + class BackoffMock: def __init__(self): @@ -39,6 +49,37 @@ def test_retry_on_timeout_retry(self, Class, retries): assert isinstance(c.retry, Retry) assert c.retry._retries == retries + @pytest.mark.parametrize("Class", [Connection, UnixDomainSocketConnection]) + def test_retry_on_error(self, Class): + c = Class(retry_on_error=[ReadOnlyError]) + assert c.retry_on_error == [ReadOnlyError] + assert isinstance(c.retry, Retry) + assert c.retry._retries == 1 + + @pytest.mark.parametrize("Class", [Connection, UnixDomainSocketConnection]) + def test_retry_on_error_empty_value(self, Class): + c = Class(retry_on_error=[]) + assert c.retry_on_error == [] + assert isinstance(c.retry, Retry) + assert c.retry._retries == 0 + + @pytest.mark.parametrize("Class", [Connection, UnixDomainSocketConnection]) + def test_retry_on_error_and_timeout(self, Class): + c = Class( + retry_on_error=[ReadOnlyError, BusyLoadingError], retry_on_timeout=True + ) + assert c.retry_on_error == [ReadOnlyError, BusyLoadingError, TimeoutError] + assert isinstance(c.retry, Retry) + assert c.retry._retries == 1 + + @pytest.mark.parametrize("retries", range(10)) + @pytest.mark.parametrize("Class", [Connection, UnixDomainSocketConnection]) + def test_retry_on_error_retry(self, Class, retries): + c = Class(retry_on_error=[ReadOnlyError], retry=Retry(NoBackoff(), retries)) + assert c.retry_on_error == [ReadOnlyError] + assert isinstance(c.retry, Retry) + assert c.retry._retries == retries + class TestRetry: "Test that Retry calls backoff and retries the expected number of times" @@ -65,3 +106,85 @@ def test_retry(self, retries): assert self.actual_failures == 1 + retries assert backoff.reset_calls == 1 assert backoff.calls == retries + + +@pytest.mark.onlynoncluster +class TestRedisClientRetry: + "Test the standalone Redis client behavior with retries" + + def test_client_retry_on_error_with_success(self, request): + with patch.object(Redis, "parse_response") as parse_response: + + def mock_parse_response(connection, *args, **options): + def ok_response(connection, *args, **options): + return "MOCK_OK" + + parse_response.side_effect = ok_response + raise ReadOnlyError() + + parse_response.side_effect = mock_parse_response + r = _get_client(Redis, request, retry_on_error=[ReadOnlyError]) + assert r.get("foo") == "MOCK_OK" + assert parse_response.call_count == 2 + + def test_client_retry_on_error_raise(self, request): + with patch.object(Redis, "parse_response") as parse_response: + parse_response.side_effect = BusyLoadingError() + retries = 3 + r = _get_client( + Redis, + request, + retry_on_error=[ReadOnlyError, BusyLoadingError], + retry=Retry(NoBackoff(), retries), + ) + with pytest.raises(BusyLoadingError): + try: + r.get("foo") + finally: + assert parse_response.call_count == retries + 1 + + def test_client_retry_on_error_different_error_raised(self, request): + with patch.object(Redis, "parse_response") as parse_response: + parse_response.side_effect = TimeoutError() + retries = 3 + r = _get_client( + Redis, + request, + retry_on_error=[ReadOnlyError], + retry=Retry(NoBackoff(), retries), + ) + with pytest.raises(TimeoutError): + try: + r.get("foo") + finally: + assert parse_response.call_count == 1 + + def test_client_retry_on_error_and_timeout(self, request): + with patch.object(Redis, "parse_response") as parse_response: + parse_response.side_effect = TimeoutError() + retries = 3 + r = _get_client( + Redis, + request, + retry_on_error=[ReadOnlyError], + retry_on_timeout=True, + retry=Retry(NoBackoff(), retries), + ) + with pytest.raises(TimeoutError): + try: + r.get("foo") + finally: + assert parse_response.call_count == retries + 1 + + def test_client_retry_on_timeout(self, request): + with patch.object(Redis, "parse_response") as parse_response: + parse_response.side_effect = TimeoutError() + retries = 3 + r = _get_client( + Redis, request, retry_on_timeout=True, retry=Retry(NoBackoff(), retries) + ) + with pytest.raises(TimeoutError): + try: + r.get("foo") + finally: + assert parse_response.call_count == retries + 1 From d6cb997bc7b96f4e646a497a89f9466c97aeffef Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Thu, 23 Dec 2021 11:42:44 +0200 Subject: [PATCH 0318/1164] Fixing read race condition during pubsub (#1737) --- redis/client.py | 74 ++++++++++++++++++++++++++++++++++++++++---- tests/test_pubsub.py | 43 +++++++++++++++++++------ 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/redis/client.py b/redis/client.py index 0236f20a32..5116482cc6 100755 --- a/redis/client.py +++ b/redis/client.py @@ -1288,18 +1288,17 @@ def __init__( self.shard_hint = shard_hint self.ignore_subscribe_messages = ignore_subscribe_messages self.connection = None + self.subscribed_event = threading.Event() # we need to know the encoding options for this connection in order # to lookup channel and pattern names for callback handlers. self.encoder = encoder if self.encoder is None: self.encoder = self.connection_pool.get_encoder() + self.health_check_response_b = self.encoder.encode(self.HEALTH_CHECK_MESSAGE) if self.encoder.decode_responses: self.health_check_response = ["pong", self.HEALTH_CHECK_MESSAGE] else: - self.health_check_response = [ - b"pong", - self.encoder.encode(self.HEALTH_CHECK_MESSAGE), - ] + self.health_check_response = [b"pong", self.health_check_response_b] self.reset() def __enter__(self): @@ -1324,9 +1323,11 @@ def reset(self): self.connection_pool.release(self.connection) self.connection = None self.channels = {} + self.health_check_response_counter = 0 self.pending_unsubscribe_channels = set() self.patterns = {} self.pending_unsubscribe_patterns = set() + self.subscribed_event.clear() def close(self): self.reset() @@ -1352,7 +1353,7 @@ def on_connect(self, connection): @property def subscribed(self): "Indicates if there are subscriptions to any channels or patterns" - return bool(self.channels or self.patterns) + return self.subscribed_event.is_set() def execute_command(self, *args): "Execute a publish/subscribe command" @@ -1370,8 +1371,28 @@ def execute_command(self, *args): self.connection.register_connect_callback(self.on_connect) connection = self.connection kwargs = {"check_health": not self.subscribed} + if not self.subscribed: + self.clean_health_check_responses() self._execute(connection, connection.send_command, *args, **kwargs) + def clean_health_check_responses(self): + """ + If any health check responses are present, clean them + """ + ttl = 10 + conn = self.connection + while self.health_check_response_counter > 0 and ttl > 0: + if self._execute(conn, conn.can_read, timeout=conn.socket_timeout): + response = self._execute(conn, conn.read_response) + if self.is_health_check_response(response): + self.health_check_response_counter -= 1 + else: + raise PubSubError( + "A non health check response was cleaned by " + "execute_command: {0}".format(response) + ) + ttl -= 1 + def _disconnect_raise_connect(self, conn, error): """ Close the connection and raise an exception @@ -1411,11 +1432,23 @@ def parse_response(self, block=True, timeout=0): return None response = self._execute(conn, conn.read_response) - if conn.health_check_interval and response == self.health_check_response: + if self.is_health_check_response(response): # ignore the health check message as user might not expect it + self.health_check_response_counter -= 1 return None return response + def is_health_check_response(self, response): + """ + Check if the response is a health check response. + If there are no subscriptions redis responds to PING command with a + bulk response, instead of a multi-bulk with "pong" and the response. + """ + return response in [ + self.health_check_response, # If there was a subscription + self.health_check_response_b, # If there wasn't + ] + def check_health(self): conn = self.connection if conn is None: @@ -1426,6 +1459,7 @@ def check_health(self): if conn.health_check_interval and time.time() > conn.next_health_check: conn.send_command("PING", self.HEALTH_CHECK_MESSAGE, check_health=False) + self.health_check_response_counter += 1 def _normalize_keys(self, data): """ @@ -1455,6 +1489,11 @@ def psubscribe(self, *args, **kwargs): # for the reconnection. new_patterns = self._normalize_keys(new_patterns) self.patterns.update(new_patterns) + if not self.subscribed: + # Set the subscribed_event flag to True + self.subscribed_event.set() + # Clear the health check counter + self.health_check_response_counter = 0 self.pending_unsubscribe_patterns.difference_update(new_patterns) return ret_val @@ -1489,6 +1528,11 @@ def subscribe(self, *args, **kwargs): # for the reconnection. new_channels = self._normalize_keys(new_channels) self.channels.update(new_channels) + if not self.subscribed: + # Set the subscribed_event flag to True + self.subscribed_event.set() + # Clear the health check counter + self.health_check_response_counter = 0 self.pending_unsubscribe_channels.difference_update(new_channels) return ret_val @@ -1520,6 +1564,20 @@ def get_message(self, ignore_subscribe_messages=False, timeout=0): before returning. Timeout should be specified as a floating point number. """ + if not self.subscribed: + # Wait for subscription + start_time = time.time() + if self.subscribed_event.wait(timeout) is True: + # The connection was subscribed during the timeout time frame. + # The timeout should be adjusted based on the time spent + # waiting for the subscription + time_spent = time.time() - start_time + timeout = max(0.0, timeout - time_spent) + else: + # The connection isn't subscribed to any channels or patterns, + # so no messages are available + return None + response = self.parse_response(block=False, timeout=timeout) if response: return self.handle_message(response, ignore_subscribe_messages) @@ -1575,6 +1633,10 @@ def handle_message(self, response, ignore_subscribe_messages=False): if channel in self.pending_unsubscribe_channels: self.pending_unsubscribe_channels.remove(channel) self.channels.pop(channel, None) + if not self.channels and not self.patterns: + # There are no subscriptions anymore, set subscribed_event flag + # to false + self.subscribed_event.clear() if message_type in self.PUBLISH_MESSAGE_TYPES: # if there's a message handler, invoke it diff --git a/tests/test_pubsub.py b/tests/test_pubsub.py index 20ae0a05c1..23af46153f 100644 --- a/tests/test_pubsub.py +++ b/tests/test_pubsub.py @@ -2,6 +2,7 @@ import threading import time from unittest import mock +from unittest.mock import patch import pytest @@ -348,15 +349,6 @@ def test_unicode_pattern_message_handler(self, r): "pmessage", channel, "test message", pattern=pattern ) - def test_get_message_without_subscribe(self, r): - p = r.pubsub() - with pytest.raises(RuntimeError) as info: - p.get_message() - expect = ( - "connection not set: " "did you forget to call subscribe() or psubscribe()?" - ) - assert expect in info.exconly() - class TestPubSubAutoDecoding: "These tests only validate that we get unicode values back" @@ -549,6 +541,39 @@ def test_get_message_with_timeout_returns_none(self, r): assert wait_for_message(p) == make_message("subscribe", "foo", 1) assert p.get_message(timeout=0.01) is None + def test_get_message_not_subscribed_return_none(self, r): + p = r.pubsub() + assert p.subscribed is False + assert p.get_message() is None + assert p.get_message(timeout=0.1) is None + with patch.object(threading.Event, "wait") as mock: + mock.return_value = False + assert p.get_message(timeout=0.01) is None + assert mock.called + + def test_get_message_subscribe_during_waiting(self, r): + p = r.pubsub() + + def poll(ps, expected_res): + assert ps.get_message() is None + message = ps.get_message(timeout=1) + assert message == expected_res + + subscribe_response = make_message("subscribe", "foo", 1) + poller = threading.Thread(target=poll, args=(p, subscribe_response)) + poller.start() + time.sleep(0.2) + p.subscribe("foo") + poller.join() + + def test_get_message_wait_for_subscription_not_being_called(self, r): + p = r.pubsub() + p.subscribe("foo") + with patch.object(threading.Event, "wait") as mock: + assert p.subscribed is True + assert wait_for_message(p) == make_message("subscribe", "foo", 1) + assert mock.called is False + class TestPubSubWorkerThread: @pytest.mark.skipif( From 3347888bfa19f9e82a71ae6dc13a4837c87ea893 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Thu, 23 Dec 2021 12:18:02 +0200 Subject: [PATCH 0319/1164] Retry on error exception and timeout fixes (#1821) --- redis/cluster.py | 101 ++++++++++++++++++++---------------------- tests/test_cluster.py | 39 ++++------------ 2 files changed, 58 insertions(+), 82 deletions(-) diff --git a/redis/cluster.py b/redis/cluster.py index 0c2fc715d7..5707a9da32 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -17,6 +17,7 @@ ClusterCrossSlotError, ClusterDownError, ClusterError, + ConnectionError, DataError, MasterDownError, MovedError, @@ -374,6 +375,12 @@ class RedisCluster(RedisClusterCommands): ), ) + ERRORS_ALLOW_RETRY = ( + ConnectionError, + TimeoutError, + ClusterDownError, + ) + def __init__( self, host=None, @@ -385,8 +392,6 @@ def __init__( reinitialize_steps=10, read_from_replicas=False, url=None, - retry_on_timeout=False, - retry=None, **kwargs, ): """ @@ -417,11 +422,6 @@ def __init__( :cluster_error_retry_attempts: 'int' Retry command execution attempts when encountering ClusterDownError or ConnectionError - :retry_on_timeout: 'bool' - To specify a retry policy, first set `retry_on_timeout` to `True` - then set `retry` to a valid `Retry` object - :retry: 'Retry' - a `Retry` object :reinitialize_steps: 'int' Specifies the number of MOVED errors that need to occur before reinitializing the whole cluster topology. If a MOVED error occurs @@ -452,9 +452,6 @@ def __init__( "Argument 'db' is not possible to use in cluster mode" ) - if retry_on_timeout: - kwargs.update({"retry_on_timeout": retry_on_timeout, "retry": retry}) - # Get the startup node/s from_url = False if url is not None: @@ -850,7 +847,7 @@ def _parse_target_nodes(self, target_nodes): def execute_command(self, *args, **kwargs): """ - Wrapper for ClusterDownError and ConnectionError error handling. + Wrapper for ERRORS_ALLOW_RETRY error handling. It will try the number of times specified by the config option "self.cluster_error_retry_attempts" which defaults to 3 unless manually @@ -865,18 +862,19 @@ def execute_command(self, *args, **kwargs): dict """ target_nodes_specified = False - target_nodes = kwargs.pop("target_nodes", None) - if target_nodes is not None and not self._is_nodes_flag(target_nodes): - target_nodes = self._parse_target_nodes(target_nodes) + target_nodes = None + passed_targets = kwargs.pop("target_nodes", None) + if passed_targets is not None and not self._is_nodes_flag(passed_targets): + target_nodes = self._parse_target_nodes(passed_targets) target_nodes_specified = True - # If ClusterDownError/ConnectionError were thrown, the nodes - # and slots cache were reinitialized. We will retry executing the - # command with the updated cluster setup only when the target nodes - # can be determined again with the new cache tables. Therefore, - # when target nodes were passed to this function, we cannot retry - # the command execution since the nodes may not be valid anymore - # after the tables were reinitialized. So in case of passed target - # nodes, retry_attempts will be set to 1. + # If an error that allows retrying was thrown, the nodes and slots + # cache were reinitialized. We will retry executing the command with + # the updated cluster setup only when the target nodes can be + # determined again with the new cache tables. Therefore, when target + # nodes were passed to this function, we cannot retry the command + # execution since the nodes may not be valid anymore after the tables + # were reinitialized. So in case of passed target nodes, + # retry_attempts will be set to 1. retry_attempts = ( 1 if target_nodes_specified else self.cluster_error_retry_attempts ) @@ -887,7 +885,7 @@ def execute_command(self, *args, **kwargs): if not target_nodes_specified: # Determine the nodes to execute the command on target_nodes = self._determine_nodes( - *args, **kwargs, nodes_flag=target_nodes + *args, **kwargs, nodes_flag=passed_targets ) if not target_nodes: raise RedisClusterException( @@ -897,11 +895,14 @@ def execute_command(self, *args, **kwargs): res[node.name] = self._execute_command(node, *args, **kwargs) # Return the processed result return self._process_result(args[0], res, **kwargs) - except (ClusterDownError, ConnectionError) as e: - # The nodes and slots cache were reinitialized. - # Try again with the new cluster setup. All other errors - # should be raised. - exception = e + except BaseException as e: + if type(e) in RedisCluster.ERRORS_ALLOW_RETRY: + # The nodes and slots cache were reinitialized. + # Try again with the new cluster setup. + exception = e + else: + # All other errors should be raised. + raise e # If it fails the configured number of times then raise exception back # to caller of this method @@ -953,11 +954,11 @@ def _execute_command(self, target_node, *args, **kwargs): ) return response - except (RedisClusterException, BusyLoadingError): - log.exception("RedisClusterException || BusyLoadingError") + except (RedisClusterException, BusyLoadingError) as e: + log.exception(type(e)) raise - except ConnectionError: - log.exception("ConnectionError") + except (ConnectionError, TimeoutError) as e: + log.exception(type(e)) # ConnectionError can also be raised if we couldn't get a # connection from the pool before timing out, so check that # this is an actual connection before attempting to disconnect. @@ -976,13 +977,6 @@ def _execute_command(self, target_node, *args, **kwargs): # and try again with the new setup self.nodes_manager.initialize() raise - except TimeoutError: - log.exception("TimeoutError") - if connection is not None: - connection.disconnect() - - if ttl < self.RedisClusterRequestTTL / 2: - time.sleep(0.05) except MovedError as e: # First, we will try to patch the slots/nodes cache with the # redirected node output and try again. If MovedError exceeds @@ -1016,7 +1010,7 @@ def _execute_command(self, target_node, *args, **kwargs): # ClusterDownError can occur during a failover and to get # self-healed, we will try to reinitialize the cluster layout # and retry executing the command - time.sleep(0.05) + time.sleep(0.25) self.nodes_manager.initialize() raise e except ResponseError as e: @@ -1342,7 +1336,7 @@ def initialize(self): raise RedisClusterException( "Cluster mode is not enabled on this node" ) - cluster_slots = r.execute_command("CLUSTER SLOTS") + cluster_slots = str_if_bytes(r.execute_command("CLUSTER SLOTS")) startup_nodes_reachable = True except (ConnectionError, TimeoutError) as e: msg = e.__str__ @@ -1631,21 +1625,20 @@ def get_redis_connection(self): return self.node.redis_connection -ERRORS_ALLOW_RETRY = ( - ConnectionError, - TimeoutError, - MovedError, - AskError, - TryAgainError, -) - - class ClusterPipeline(RedisCluster): """ Support for Redis pipeline in cluster mode """ + ERRORS_ALLOW_RETRY = ( + ConnectionError, + TimeoutError, + MovedError, + AskError, + TryAgainError, + ) + def __init__( self, nodes_manager, @@ -1653,7 +1646,7 @@ def __init__( cluster_response_callbacks=None, startup_nodes=None, read_from_replicas=False, - cluster_error_retry_attempts=3, + cluster_error_retry_attempts=5, reinitialize_steps=10, **kwargs, ): @@ -1915,7 +1908,11 @@ def _send_cluster_commands( # collect all the commands we are allowed to retry. # (MOVED, ASK, or connection errors or timeout errors) attempt = sorted( - (c for c in attempt if isinstance(c.result, ERRORS_ALLOW_RETRY)), + ( + c + for c in attempt + if isinstance(c.result, ClusterPipeline.ERRORS_ALLOW_RETRY) + ), key=lambda x: x.position, ) if attempt and allow_redirections: diff --git a/tests/test_cluster.py b/tests/test_cluster.py index e1c8c34768..496ed9818b 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -22,6 +22,7 @@ from redis.exceptions import ( AskError, ClusterDownError, + ConnectionError, DataError, MovedError, NoPermissionError, @@ -555,46 +556,24 @@ def test_all_nodes_masters(self, r): for node in r.get_primaries(): assert node in nodes - def test_cluster_down_overreaches_retry_attempts(self): + @pytest.mark.parametrize("error", RedisCluster.ERRORS_ALLOW_RETRY) + def test_cluster_down_overreaches_retry_attempts(self, error): """ - When ClusterDownError is thrown, test that we retry executing the - command as many times as configured in cluster_error_retry_attempts + When error that allows retry is thrown, test that we retry executing + the command as many times as configured in cluster_error_retry_attempts and then raise the exception """ with patch.object(RedisCluster, "_execute_command") as execute_command: - def raise_cluster_down_error(target_node, *args, **kwargs): + def raise_error(target_node, *args, **kwargs): execute_command.failed_calls += 1 - raise ClusterDownError( - "CLUSTERDOWN The cluster is down. Use CLUSTER INFO for " - "more information" - ) + raise error("mocked error") - execute_command.side_effect = raise_cluster_down_error + execute_command.side_effect = raise_error rc = get_mocked_redis_client(host=default_host, port=default_port) - with pytest.raises(ClusterDownError): - rc.get("bar") - assert execute_command.failed_calls == rc.cluster_error_retry_attempts - - def test_connection_error_overreaches_retry_attempts(self): - """ - When ConnectionError is thrown, test that we retry executing the - command as many times as configured in cluster_error_retry_attempts - and then raise the exception - """ - with patch.object(RedisCluster, "_execute_command") as execute_command: - - def raise_conn_error(target_node, *args, **kwargs): - execute_command.failed_calls += 1 - raise ConnectionError() - - execute_command.side_effect = raise_conn_error - - rc = get_mocked_redis_client(host=default_host, port=default_port) - - with pytest.raises(ConnectionError): + with pytest.raises(error): rc.get("bar") assert execute_command.failed_calls == rc.cluster_error_retry_attempts From 83570a74483794a1465e10c10e3405d0fbbf0589 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 23 Dec 2021 12:18:34 +0200 Subject: [PATCH 0320/1164] Support for SELECT (#1825) --- redis/commands/core.py | 7 +++++++ tests/test_commands.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/redis/commands/core.py b/redis/commands/core.py index 0823315a01..4f0accd957 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -780,6 +780,13 @@ def swapdb(self, first, second, **kwargs): """ return self.execute_command("SWAPDB", first, second, **kwargs) + def select(self, index, **kwargs): + """Select the Redis logical database at index. + + See: https://redis.io/commands/select + """ + return self.execute_command("SELECT", index, **kwargs) + def info(self, section=None, **kwargs): """ Returns a dictionary containing information about the Redis server diff --git a/tests/test_commands.py b/tests/test_commands.py index 09121617cb..f4ffb6329c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -695,6 +695,12 @@ def test_role(self, r): assert isinstance(r.role()[1], int) assert isinstance(r.role()[2], list) + @pytest.mark.onlynoncluster + def test_select(self, r): + assert r.select(5) + assert r.select(2) + assert r.select(9) + @pytest.mark.onlynoncluster def test_slowlog_get(self, r, slowlog): assert r.slowlog_reset() From 04b8d34e212723974b9b1f484fe7cd9e93f0e315 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 23 Dec 2021 16:23:06 +0200 Subject: [PATCH 0321/1164] Marking STRALGO as prior to redis 7 (#1829) --- tests/test_commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_commands.py b/tests/test_commands.py index f4ffb6329c..897fcceed1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1346,6 +1346,7 @@ def test_setrange(self, r): assert r["a"] == b"abcdef12345" @skip_if_server_version_lt("6.0.0") + @skip_if_server_version_gte("7.0.0") def test_stralgo_lcs(self, r): key1 = "{foo}key1" key2 = "{foo}key2" From f03d008ba226c698e266158012b47b348b89b503 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Sun, 26 Dec 2021 08:06:04 +0100 Subject: [PATCH 0322/1164] SRTALGO - skip for redis versions greater than 7.0.0 (#1831) --- tests/test_commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_commands.py b/tests/test_commands.py index 897fcceed1..b28b63ea6e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1379,6 +1379,7 @@ def test_stralgo_lcs(self, r): ) == {"len": len(res), "matches": [[4, (4, 7), (5, 8)]]} @skip_if_server_version_lt("6.0.0") + @skip_if_server_version_gte("7.0.0") def test_stralgo_negative(self, r): with pytest.raises(exceptions.DataError): r.stralgo("ISSUB", "value1", "value2") From b426d0d41cc28f1b0f6ec7092cfb819ce00a6e16 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 26 Dec 2021 15:02:43 +0200 Subject: [PATCH 0323/1164] OCSP stapling support (#1820) --- redis/client.py | 2 + redis/connection.py | 17 ++++- redis/exceptions.py | 4 ++ redis/ocsp.py | 159 ++++++++++++++++++++++++++++++++++++++++++++ redis/utils.py | 7 ++ setup.py | 1 + tasks.py | 2 +- tests/conftest.py | 18 +++++ tests/test_ssl.py | 102 +++++++++++++++++++++++++++- tox.ini | 9 +-- 10 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 redis/ocsp.py diff --git a/redis/client.py b/redis/client.py index 5116482cc6..0984a7c243 100755 --- a/redis/client.py +++ b/redis/client.py @@ -878,6 +878,7 @@ def __init__( ssl_ca_path=None, ssl_check_hostname=False, ssl_password=None, + ssl_validate_ocsp=False, max_connections=None, single_connection_client=False, health_check_interval=0, @@ -956,6 +957,7 @@ def __init__( "ssl_check_hostname": ssl_check_hostname, "ssl_password": ssl_password, "ssl_ca_path": ssl_ca_path, + "ssl_validate_ocsp": ssl_validate_ocsp, } ) connection_pool = ConnectionPool(**kwargs) diff --git a/redis/connection.py b/redis/connection.py index a349a0f23b..bde74b17df 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -31,7 +31,7 @@ TimeoutError, ) from redis.retry import Retry -from redis.utils import HIREDIS_AVAILABLE, str_if_bytes +from redis.utils import CRYPTOGRAPHY_AVAILABLE, HIREDIS_AVAILABLE, str_if_bytes try: import ssl @@ -907,6 +907,7 @@ def __init__( ssl_check_hostname=False, ssl_ca_path=None, ssl_password=None, + ssl_validate_ocsp=False, **kwargs, ): """Constructor @@ -948,6 +949,7 @@ def __init__( self.ca_path = ssl_ca_path self.check_hostname = ssl_check_hostname self.certificate_password = ssl_password + self.ssl_validate_ocsp = ssl_validate_ocsp def _connect(self): "Wrap the socket with SSL support" @@ -963,7 +965,18 @@ def _connect(self): ) if self.ca_certs is not None or self.ca_path is not None: context.load_verify_locations(cafile=self.ca_certs, capath=self.ca_path) - return context.wrap_socket(sock, server_hostname=self.host) + sslsock = context.wrap_socket(sock, server_hostname=self.host) + if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False: + raise RedisError("cryptography is not installed.") + elif self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE: + from .ocsp import OCSPVerifier + + o = OCSPVerifier(sslsock, self.host, self.port, self.ca_certs) + if o.is_valid(): + return sslsock + else: + raise ConnectionError("ocsp validation error") + return sslsock class UnixDomainSocketConnection(Connection): diff --git a/redis/exceptions.py b/redis/exceptions.py index e37cad358e..d18b354454 100644 --- a/redis/exceptions.py +++ b/redis/exceptions.py @@ -17,6 +17,10 @@ class AuthenticationError(ConnectionError): pass +class AuthorizationError(ConnectionError): + pass + + class BusyLoadingError(ConnectionError): pass diff --git a/redis/ocsp.py b/redis/ocsp.py new file mode 100644 index 0000000000..49aaddf48b --- /dev/null +++ b/redis/ocsp.py @@ -0,0 +1,159 @@ +import base64 +import ssl +from urllib.parse import urljoin, urlparse + +import cryptography.hazmat.primitives.hashes +import requests +from cryptography import hazmat, x509 +from cryptography.hazmat import backends +from cryptography.x509 import ocsp + +from redis.exceptions import AuthorizationError, ConnectionError + + +class OCSPVerifier: + """A class to verify ssl sockets for RFC6960/RFC6961. + + @see https://datatracker.ietf.org/doc/html/rfc6960 + @see https://datatracker.ietf.org/doc/html/rfc6961 + """ + + def __init__(self, sock, host, port, ca_certs=None): + self.SOCK = sock + self.HOST = host + self.PORT = port + self.CA_CERTS = ca_certs + + def _bin2ascii(self, der): + """Convert SSL certificates in a binary (DER) format to ASCII PEM.""" + + pem = ssl.DER_cert_to_PEM_cert(der) + cert = x509.load_pem_x509_certificate(pem.encode(), backends.default_backend()) + return cert + + def components_from_socket(self): + """This function returns the certificate, primary issuer, and primary ocsp server + in the chain for a socket already wrapped with ssl. + """ + + # convert the binary certifcate to text + der = self.SOCK.getpeercert(True) + if der is False: + raise ConnectionError("no certificate found for ssl peer") + cert = self._bin2ascii(der) + return self._certificate_components(cert) + + def _certificate_components(self, cert): + """Given an SSL certificate, retract the useful components for + validating the certificate status with an OCSP server. + + Args: + cert ([bytes]): A PEM encoded ssl certificate + """ + + try: + aia = cert.extensions.get_extension_for_oid( + x509.oid.ExtensionOID.AUTHORITY_INFORMATION_ACCESS + ).value + except cryptography.x509.extensions.ExtensionNotFound: + raise ConnectionError("No AIA information present in ssl certificate") + + # fetch certificate issuers + issuers = [ + i + for i in aia + if i.access_method == x509.oid.AuthorityInformationAccessOID.CA_ISSUERS + ] + try: + issuer = issuers[0].access_location.value + except IndexError: + raise ConnectionError("no issuers in certificate") + + # now, the series of ocsp server entries + ocsps = [ + i + for i in aia + if i.access_method == x509.oid.AuthorityInformationAccessOID.OCSP + ] + + try: + ocsp = ocsps[0].access_location.value + except IndexError: + raise ConnectionError("no ocsp servers in certificate") + + return cert, issuer, ocsp + + def components_from_direct_connection(self): + """Return the certificate, primary issuer, and primary ocsp server + from the host defined by the socket. This is useful in cases where + different certificates are occasionally presented. + """ + + pem = ssl.get_server_certificate((self.HOST, self.PORT), ca_certs=self.CA_CERTS) + cert = x509.load_pem_x509_certificate(pem.encode(), backends.default_backend()) + return self._certificate_components(cert) + + def build_certificate_url(self, server, cert, issuer_cert): + """Return the complete url to the ocsp""" + orb = ocsp.OCSPRequestBuilder() + + # add_certificate returns an initialized OCSPRequestBuilder + orb = orb.add_certificate( + cert, issuer_cert, cryptography.hazmat.primitives.hashes.SHA256() + ) + request = orb.build() + + path = base64.b64encode( + request.public_bytes(hazmat.primitives.serialization.Encoding.DER) + ) + url = urljoin(server, path.decode("ascii")) + return url + + def check_certificate(self, server, cert, issuer_url): + """Checks the validitity of an ocsp server for an issuer""" + + r = requests.get(issuer_url) + if not r.ok: + raise ConnectionError("failed to fetch issuer certificate") + der = r.content + issuer_cert = self._bin2ascii(der) + + ocsp_url = self.build_certificate_url(server, cert, issuer_cert) + + # HTTP 1.1 mandates the addition of the Host header in ocsp responses + header = { + "Host": urlparse(ocsp_url).netloc, + "Content-Type": "application/ocsp-request", + } + r = requests.get(ocsp_url, headers=header) + if not r.ok: + raise ConnectionError("failed to fetch ocsp certificate") + + ocsp_response = ocsp.load_der_ocsp_response(r.content) + if ocsp_response.response_status == ocsp.OCSPResponseStatus.UNAUTHORIZED: + raise AuthorizationError( + "you are not authorized to view this ocsp certificate" + ) + if ocsp_response.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL: + if ocsp_response.certificate_status == ocsp.OCSPCertStatus.REVOKED: + return False + else: + return True + else: + return False + + def is_valid(self): + """Returns the validity of the certificate wrapping our socket. + This first retrieves for validate the certificate, issuer_url, + and ocsp_server for certificate validate. Then retrieves the + issuer certificate from the issuer_url, and finally checks + the valididy of OCSP revocation status. + """ + + # validate the certificate + try: + cert, issuer_url, ocsp_server = self.components_from_socket() + return self.check_certificate(ocsp_server, cert, issuer_url) + except AuthorizationError: + cert, issuer_url, ocsp_server = self.components_from_direct_connection() + return self.check_certificate(ocsp_server, cert, issuer_url) diff --git a/redis/utils.py b/redis/utils.py index 50961cb767..56fec49b70 100644 --- a/redis/utils.py +++ b/redis/utils.py @@ -7,6 +7,13 @@ except ImportError: HIREDIS_AVAILABLE = False +try: + import cryptography # noqa + + CRYPTOGRAPHY_AVAILABLE = True +except ImportError: + CRYPTOGRAPHY_AVAILABLE = False + def from_url(url, **kwargs): """ diff --git a/setup.py b/setup.py index 524ea845d1..83b4a449dd 100644 --- a/setup.py +++ b/setup.py @@ -48,5 +48,6 @@ ], extras_require={ "hiredis": ["hiredis>=1.0.0"], + "cryptography": ["cryptography>=36.0.1", "requests>=2.26.0"], }, ) diff --git a/tasks.py b/tasks.py index 98ed483fb1..0fbb5e8352 100644 --- a/tasks.py +++ b/tasks.py @@ -65,7 +65,7 @@ def standalone_tests(c): with and without hiredis.""" print("Starting Redis tests") _generate_keys() - run("tox -e standalone-'{plain,hiredis}'") + run("tox -e standalone-'{plain,hiredis,cryptography}'") @task diff --git a/tests/conftest.py b/tests/conftest.py index 0149166d65..505a6e47e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -170,6 +170,24 @@ def skip_ifnot_redis_enterprise(): return pytest.mark.skipif(check, reason="Not running in redis enterprise") +def skip_if_nocryptography(): + try: + import cryptography # noqa + + return pytest.mark.skipif(False, reason="Cryptography dependency found") + except ImportError: + return pytest.mark.skipif(True, reason="No cryptography dependency") + + +def skip_if_cryptography(): + try: + import cryptography # noqa + + return pytest.mark.skipif(True, reason="Cryptography dependency found") + except ImportError: + return pytest.mark.skipif(False, reason="No cryptography dependency") + + def _get_client( cls, request, single_connection_client=True, flushdb=True, from_url=None, **kwargs ): diff --git a/tests/test_ssl.py b/tests/test_ssl.py index 70f9e58b76..a2f66b26ef 100644 --- a/tests/test_ssl.py +++ b/tests/test_ssl.py @@ -1,10 +1,14 @@ import os +import socket +import ssl from urllib.parse import urlparse import pytest import redis -from redis.exceptions import ConnectionError +from redis.exceptions import ConnectionError, RedisError + +from .conftest import skip_if_cryptography, skip_if_nocryptography @pytest.mark.ssl @@ -59,3 +63,99 @@ def test_validating_self_signed_certificate(self, request): ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"), ) assert r.ping() + + def _create_oscp_conn(self, request): + ssl_url = request.config.option.redis_ssl_url + p = urlparse(ssl_url)[1].split(":") + r = redis.Redis( + host=p[0], + port=p[1], + ssl=True, + ssl_certfile=os.path.join(self.CERT_DIR, "server-cert.pem"), + ssl_keyfile=os.path.join(self.CERT_DIR, "server-key.pem"), + ssl_cert_reqs="required", + ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"), + ssl_validate_ocsp=True, + ) + return r + + @skip_if_cryptography() + def test_ssl_ocsp_called(self, request): + r = self._create_oscp_conn(request) + with pytest.raises(RedisError) as e: + assert r.ping() + assert "cryptography not installed" in str(e) + + @skip_if_nocryptography() + def test_ssl_ocsp_called_withcrypto(self, request): + r = self._create_oscp_conn(request) + with pytest.raises(ConnectionError) as e: + assert r.ping() + assert "No AIA information present in ssl certificate" in str(e) + + # rediss://, url based + ssl_url = request.config.option.redis_ssl_url + sslclient = redis.from_url(ssl_url) + with pytest.raises(ConnectionError) as e: + sslclient.ping() + assert "No AIA information present in ssl certificate" in str(e) + + @skip_if_nocryptography() + def test_valid_ocsp_cert_http(self): + from redis.ocsp import OCSPVerifier + + hostnames = ["github.com", "aws.amazon.com", "ynet.co.il", "microsoft.com"] + for hostname in hostnames: + context = ssl.create_default_context() + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + assert ocsp.is_valid() + + @skip_if_nocryptography() + def test_revoked_ocsp_certificate(self): + from redis.ocsp import OCSPVerifier + + context = ssl.create_default_context() + hostname = "revoked.badssl.com" + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + assert ocsp.is_valid() is False + + @skip_if_nocryptography() + def test_unauthorized_ocsp(self): + from redis.ocsp import OCSPVerifier + + context = ssl.create_default_context() + hostname = "stackoverflow.com" + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + with pytest.raises(ConnectionError): + ocsp.is_valid() + + @skip_if_nocryptography() + def test_ocsp_not_present_in_response(self): + from redis.ocsp import OCSPVerifier + + context = ssl.create_default_context() + hostname = "google.co.il" + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + assert ocsp.is_valid() is False + + @skip_if_nocryptography() + def test_unauthorized_then_direct(self): + from redis.ocsp import OCSPVerifier + + # these certificates on the socket end return unauthorized + # then the second call succeeds + hostnames = ["wikipedia.org", "squarespace.com"] + for hostname in hostnames: + context = ssl.create_default_context() + with socket.create_connection((hostname, 443)) as sock: + with context.wrap_socket(sock, server_hostname=hostname) as wrapped: + ocsp = OCSPVerifier(wrapped, hostname, 443) + assert ocsp.is_valid() diff --git a/tox.ini b/tox.ini index bceab0bf15..0da66ed4f0 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ markers = [tox] minversion = 3.2.0 requires = tox-docker -envlist = {standalone,cluster}-{plain,hiredis}-{py36,py37,py38,py39,pypy3},linters,docs +envlist = {standalone,cluster}-{plain,hiredis,cryptography}-{py36,py37,py38,py39,pypy3},linters,docs [docker:master] name = master @@ -118,6 +118,7 @@ docker = stunnel extras = hiredis: hiredis + cryptography: cryptography, requests setenv = CLUSTER_URL = "redis://localhost:16379/0" run_before = {toxinidir}/docker/stunnel/create_certs.sh @@ -145,12 +146,6 @@ commands = skipsdist = true skip_install = true -[testenv:pypy3-plain] -basepython = pypy3 - -[testenv:pypy3-hiredis] -basepython = pypy3 - [testenv:docs] deps_files = docs/requirements.txt docker = From 1b7d5bbac89d1dc65154e52360af43373d45ebc6 Mon Sep 17 00:00:00 2001 From: Chayim Date: Sun, 26 Dec 2021 15:23:03 +0200 Subject: [PATCH 0324/1164] 4.1.0 (#1828) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 83b4a449dd..7733220838 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ long_description_content_type="text/markdown", keywords=["Redis", "key-value store", "database"], license="MIT", - version="4.1.0rc2", + version="4.1.0", packages=find_packages( include=[ "redis", From deaaa536568e50b6ce958de0dd6306392e98f13e Mon Sep 17 00:00:00 2001 From: dvora-h <67596500+dvora-h@users.noreply.github.com> Date: Thu, 30 Dec 2021 11:40:48 +0200 Subject: [PATCH 0325/1164] Connection examples (#1835) Co-authored-by: Chayim I. Kirshen --- docs/conf.py | 4 +- docs/examples.rst | 8 + .../connection_example-checkpoint.ipynb | 180 +++++++++++++ {examples => docs/examples}/README.md | 0 docs/examples/connection_example.ipynb | 254 ++++++++++++++++++ docs/index.rst | 1 + docs/requirements.txt | 3 + tasks.py | 1 + tox.ini | 4 +- 9 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 docs/examples.rst create mode 100644 docs/examples/.ipynb_checkpoints/connection_example-checkpoint.ipynb rename {examples => docs/examples}/README.md (100%) create mode 100644 docs/examples/connection_example.ipynb diff --git a/docs/conf.py b/docs/conf.py index 7e83e42156..0f11442232 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,6 +27,8 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ + "nbsphinx", + "sphinx_gallery.load_style", "sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.viewcode", @@ -73,7 +75,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ["_build"] +exclude_patterns = ["_build", "**.ipynb_checkponts"] # The reST default role (used for this markup: `text`) to use for all # documents. diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 0000000000..1a82182869 --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,8 @@ +Examples +######## + +.. toctree:: + :maxdepth: 3 + :glob: + + examples/connection_example diff --git a/docs/examples/.ipynb_checkpoints/connection_example-checkpoint.ipynb b/docs/examples/.ipynb_checkpoints/connection_example-checkpoint.ipynb new file mode 100644 index 0000000000..04de8fe1c5 --- /dev/null +++ b/docs/examples/.ipynb_checkpoints/connection_example-checkpoint.ipynb @@ -0,0 +1,180 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Connect to redis running locally with default parameters " + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n" + ] + } + ], + "source": [ + "import redis\n", + "r = redis.Redis()\n", + "print(r.ping())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Overwrite default parameters - connect to redis on specific host and port using username and password" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n" + ] + } + ], + "source": [ + "import redis\n", + "r = redis.Redis(host='localhost', port=6380, username='dvora', password='redis')\n", + "print(r.ping())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Create a SSL wrapped TCP socket connection" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n" + ] + } + ], + "source": [ + "import redis\n", + "r = redis.Redis(host='localhost', port=6666, ssl=True, ssl_cert_reqs=\"none\")\n", + "print(r.ping())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Add more parameters..." + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n" + ] + } + ], + "source": [ + "import os\n", + "import redis\n", + "\n", + "ROOT = os.path.join(os.getcwd(), \"..\")\n", + "CERT_DIR = os.path.abspath(os.path.join(ROOT, \"docker\", \"stunnel\", \"keys\"))\n", + "\n", + "r = redis.Redis(\n", + " host=\"localhost\",\n", + " port=6666,\n", + " ssl=True,\n", + " ssl_certfile=os.path.join(CERT_DIR, \"server-cert.pem\"),\n", + " ssl_keyfile=os.path.join(CERT_DIR, \"server-key.pem\"),\n", + " ssl_cert_reqs=\"required\",\n", + " ssl_ca_certs=os.path.join(CERT_DIR, \"server-cert.pem\"),\n", + ")\n", + "print(r.ping())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Connect to redis client object configured from given URL\n", + "##### Three URL schemes are supported:\n", + "\n", + "##### - `redis://` creates a TCP socket connection. See more at:\n", + "##### \n", + "##### - `rediss://` creates a SSL wrapped TCP socket connection. See more at:\n", + "##### \n", + "##### - ``unix://``: creates a Unix Domain Socket connection.\n", + "\n", + "##### Parameters are passed through querystring" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n" + ] + } + ], + "source": [ + "import redis\n", + "r = redis.from_url(\"rediss://localhost:6666?ssl_cert_reqs=none\")\n", + "print(r.ping())" + ] + } + ], + "metadata": { + "interpreter": { + "hash": "d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe" + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/README.md b/docs/examples/README.md similarity index 100% rename from examples/README.md rename to docs/examples/README.md diff --git a/docs/examples/connection_example.ipynb b/docs/examples/connection_example.ipynb new file mode 100644 index 0000000000..af5193e0d7 --- /dev/null +++ b/docs/examples/connection_example.ipynb @@ -0,0 +1,254 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Connection Examples" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import redis" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a default Redis instance, running locally." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "connection = redis.Redis()\n", + "connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### by default Redis return binary responses, to decode them use decode_responses=True" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "decode_connection = redis.Redis(decode_responses=True)\n", + "connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a redis instance, specifying a host and port with credentials." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "user_connection = redis.Redis(host='localhost', port=6380, username='dvora', password='redis', decode_responses=True)\n", + "user_connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a Redis instance via SSL." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ssl_connection = redis.Redis(host='localhost', port=6666, ssl=True, ssl_cert_reqs=\"none\")\n", + "ssl_connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a Redis instance via SSL, while specifying a self-signed SSL certificate." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import os\n", + "\n", + "ROOT = os.path.join(os.getcwd(), \"..\", \"..\")\n", + "CERT_DIR = os.path.abspath(os.path.join(ROOT, \"docker\", \"stunnel\", \"keys\"))\n", + "ssl_certfile=os.path.join(CERT_DIR, \"server-cert.pem\")\n", + "ssl_keyfile=os.path.join(CERT_DIR, \"server-key.pem\")\n", + "ssl_ca_certs=os.path.join(CERT_DIR, \"server-cert.pem\")\n", + "\n", + "ssl_cert_conn = redis.Redis(\n", + " host=\"localhost\",\n", + " port=6666,\n", + " ssl=True,\n", + " ssl_certfile=ssl_certfile,\n", + " ssl_keyfile=ssl_keyfile,\n", + " ssl_cert_reqs=\"required\",\n", + " ssl_ca_certs=ssl_ca_certs,\n", + ")\n", + "ssl_cert_conn.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to Redis instances by specifying a URL scheme.\n", + "Parameters are passed to the following schems, as parameters to the url scheme.\n", + "\n", + "Three URL schemes are supported:\n", + "\n", + "- `redis://` creates a TCP socket connection. \n", + "- `rediss://` creates a SSL wrapped TCP socket connection. \n", + "- ``unix://``: creates a Unix Domain Socket connection.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "url_connection = redis.from_url(\"rediss://localhost:6666?ssl_cert_reqs=none&decode_responses=True&health_check_interval=2\")\n", + "\n", + "url_connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a Sentinel instance" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from redis.sentinel import Sentinel\n", + "sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)\n", + "sentinel.discover_master(\"redis-py-test\")" + ] + } + ], + "metadata": { + "interpreter": { + "hash": "d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe" + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/index.rst b/docs/index.rst index d088708e50..cbf5115cde 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -71,6 +71,7 @@ Module Documentation exceptions lock retry + examples Contributing ************* diff --git a/docs/requirements.txt b/docs/requirements.txt index 6dc905f6b3..bbb7dc6149 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,6 @@ sphinx<2 docutils<0.18 sphinx-rtd-theme +nbsphinx +sphinx_gallery +ipython diff --git a/tasks.py b/tasks.py index 0fbb5e8352..313986d427 100644 --- a/tasks.py +++ b/tasks.py @@ -29,6 +29,7 @@ def devenv(c): @task def build_docs(c): """Generates the sphinx documentation.""" + _generate_keys() run("tox -e docs") diff --git a/tox.ini b/tox.ini index 0da66ed4f0..3ca4533b55 100644 --- a/tox.ini +++ b/tox.ini @@ -131,7 +131,7 @@ skipsdist = true skip_install = true deps = -r {toxinidir}/dev_requirements.txt docker = {[testenv]docker} -commands = /usr/bin/echo +commands = /usr/bin/echo docker_up run_before = {[testenv]run_before} [testenv:linters] @@ -147,7 +147,7 @@ skipsdist = true skip_install = true [testenv:docs] -deps_files = docs/requirements.txt +deps = -r docs/requirements.txt docker = changedir = {toxinidir}/docs allowlist_externals = make From bc3dbb45d7236f96d614c33684a94f3e0fd9ac4a Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 30 Dec 2021 12:52:11 +0200 Subject: [PATCH 0326/1164] Documentation cleanup (#1841) --- docs/commands.rst | 30 ++++++++ docs/connections.rst | 35 ++++++++- docs/index.rst | 4 +- docs/redis_cluster_commands.rst | 7 -- docs/redis_commands.rst | 14 ---- docs/redismodules.rst | 115 +++++++++++++++--------------- docs/sentinel_commands.rst | 20 ------ redis/client.py | 1 + redis/cluster.py | 1 + redis/commands/graph/commands.py | 2 - redis/commands/search/commands.py | 46 ++++++------ redis/connection.py | 5 +- redis/sentinel.py | 2 +- 13 files changed, 149 insertions(+), 133 deletions(-) create mode 100644 docs/commands.rst delete mode 100644 docs/redis_cluster_commands.rst delete mode 100644 docs/redis_commands.rst delete mode 100644 docs/sentinel_commands.rst diff --git a/docs/commands.rst b/docs/commands.rst new file mode 100644 index 0000000000..d35f290ace --- /dev/null +++ b/docs/commands.rst @@ -0,0 +1,30 @@ +Redis Commands +############## + +Core Commands +************* + +The following functions can be used to replicate their equivalent `Redis command `_. Generally they can be used as functions on your redis connection. For the simplest example, see below: + +Getting and settings data in redis:: + + import redis + r = redis.Redis(decode_responses=True) + r.set('mykey', 'thevalueofmykey') + r.get('mykey') + +.. autoclass:: redis.commands.core.CoreCommands + :inherited-members: + +Sentinel Commands +***************** +.. autoclass:: redis.commands.sentinel.SentinelCommands + :inherited-members: + +Redis Cluster Commands +********************** + +The following `Redis commands `_ are available within a `Redis Cluster `_. Generally they can be used as functions on your redis connection. + +.. autoclass:: redis.commands.cluster.RedisClusterCommands + :inherited-members: diff --git a/docs/connections.rst b/docs/connections.rst index 973821bb07..cf4657b95b 100644 --- a/docs/connections.rst +++ b/docs/connections.rst @@ -3,10 +3,41 @@ Connecting to Redis Generic Client ************** -.. autoclass:: redis.client.Redis + +This is the client used to connect directly to a standard redis node. + +.. autoclass:: redis.Redis + :members: + +Sentinel Client +*************** + +Redis `Sentinel `_ provides high availability for Redis. There are commands that can only be executed against a redis node running in sentinel mode. Connecting to those nodes, and executing commands against them requires a Sentinel connection. + +Connection example (assumes redis redis on the ports listed below): + + >>> from redis import Sentinel + >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) + >>> sentinel.discover_master('mymaster') + ('127.0.0.1', 6379) + >>> sentinel.discover_slaves('mymaster') + [('127.0.0.1', 6380)] + +.. autoclass:: redis.sentinel.Sentinel + :members: + +.. autoclass:: redis.sentinel.SentinelConnectionPool + :members: + +Cluster Client +************** + +This client is used for connecting to a redis cluser. + +.. autoclass:: redis.cluster.RedisCluster :members: Connection Pools ***************** .. autoclass:: redis.connection.ConnectionPool - :members: \ No newline at end of file + :members: diff --git a/docs/index.rst b/docs/index.rst index cbf5115cde..51b38a2bf7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,9 +56,7 @@ Redis Command Functions .. toctree:: :maxdepth: 2 - redis_commands - redis_cluster_commands - sentinel_commands + commands redismodules Module Documentation diff --git a/docs/redis_cluster_commands.rst b/docs/redis_cluster_commands.rst deleted file mode 100644 index de520de905..0000000000 --- a/docs/redis_cluster_commands.rst +++ /dev/null @@ -1,7 +0,0 @@ -Redis Cluster Commands -###################### - -The following `Redis commands `_ are available within a `Redis Cluster `_. Generally they can be used as functions on your redis connection. - -.. autoclass:: redis.commands.cluster.RedisClusterCommands - :inherited-members: diff --git a/docs/redis_commands.rst b/docs/redis_commands.rst deleted file mode 100644 index efb76e79c6..0000000000 --- a/docs/redis_commands.rst +++ /dev/null @@ -1,14 +0,0 @@ -Redis Commands -############### - -The following functions can be used to replicate their equivalent `Redis command `_. Generally they can be used as functions on your redis connection. For the simplest example, see below: - -Getting and settings data in redis:: - - import redis - r = redis.Redis(decode_responses=True) - r.set('mykey', 'thevalueofmykey') - r.get('mykey') - -.. autoclass:: redis.commands.core.CoreCommands - :inherited-members: diff --git a/docs/redismodules.rst b/docs/redismodules.rst index 53a5008133..f31167555b 100644 --- a/docs/redismodules.rst +++ b/docs/redismodules.rst @@ -3,63 +3,49 @@ Redis Modules Commands Accessing redis module commands requires the installation of the supported `Redis module `_. For a quick start with redis modules, try the `Redismod docker `_. -RedisTimeSeries Commands -************************ -These are the commands for interacting with the `RedisTimeSeries module `_. Below is a brief example, as well as documentation on the commands themselves. +RedisBloom Commands +******************* +These are the commands for interacting with the `RedisBloom module `_. Below is a brief example, as well as documentation on the commands themselves. -**Create a timeseries object with 5 second retention** +**Create and add to a bloom filter** .. code-block:: python import redis - r = redis.Redis() - r.timeseries().create(2, retension_msecs=5) - -.. automodule:: redis.commands.timeseries.commands - :members: TimeSeriesCommands - ------ - -RedisJSON Commands -****************** - -These are the commands for interacting with the `RedisJSON module `_. Below is a brief example, as well as documentation on the commands themselves. + filter = redis.bf().create("bloom", 0.01, 1000) + filter.add("bloom", "foo") -**Create a json object** +**Create and add to a cuckoo filter** .. code-block:: python import redis - r = redis.Redis() - r.json().set("mykey", ".", {"hello": "world", "i am": ["a", "json", "object!"]} - - -.. automodule:: redis.commands.json.commands - :members: JSONCommands + filter = redis.cf().create("cuckoo", 1000) + filter.add("cuckoo", "filter") ------ +**Create Count-Min Sketch and get information** -RediSearch Commands -******************* +.. code-block:: python -These are the commands for interacting with the `RediSearch module `_. Below is a brief example, as well as documentation on the commands themselves. + import redis + r = redis.cms().initbydim("dim", 1000, 5) + r.cms().incrby("dim", ["foo"], [5]) + r.cms().info("dim") -**Create a search index, and display its information** +**Create a topk list, and access the results** .. code-block:: python import redis - r = redis.Redis() - r.ft().create_index(TextField("play", weight=5.0), TextField("ball")) - print(r.ft().info()) - + r = redis.topk().reserve("mytopk", 3, 50, 4, 0.9) + info = r.topk().info("mytopk) -.. automodule:: redis.commands.search.commands - :members: SearchCommands +.. automodule:: redis.commands.bf.commands + :members: BFCommands, CFCommands, CMSCommands, TOPKCommands ------ +------ RedisGraph Commands ******************* @@ -92,45 +78,62 @@ These are the commands for interacting with the `RedisGraph module `_. Below is a brief example, as well as documentation on the commands themselves. +These are the commands for interacting with the `RedisJSON module `_. Below is a brief example, as well as documentation on the commands themselves. -**Create and add to a bloom filter** +**Create a json object** .. code-block:: python import redis - filter = redis.bf().create("bloom", 0.01, 1000) - filter.add("bloom", "foo") + r = redis.Redis() + r.json().set("mykey", ".", {"hello": "world", "i am": ["a", "json", "object!"]} -**Create and add to a cuckoo filter** -.. code-block:: python +.. automodule:: redis.commands.json.commands + :members: JSONCommands - import redis - filter = redis.cf().create("cuckoo", 1000) - filter.add("cuckoo", "filter") +----- -**Create Count-Min Sketch and get information** +RediSearch Commands +******************* + +These are the commands for interacting with the `RediSearch module `_. Below is a brief example, as well as documentation on the commands themselves. + +**Create a search index, and display its information** .. code-block:: python import redis - r = redis.cms().initbydim("dim", 1000, 5) - r.cms().incrby("dim", ["foo"], [5]) - r.cms().info("dim") + r = redis.Redis() + r.ft().create_index(TextField("play", weight=5.0), TextField("ball")) + print(r.ft().info()) -**Create a topk list, and access the results** + +.. automodule:: redis.commands.search.commands + :members: SearchCommands + +----- + +RedisTimeSeries Commands +************************ + +These are the commands for interacting with the `RedisTimeSeries module `_. Below is a brief example, as well as documentation on the commands themselves. + + +**Create a timeseries object with 5 second retention** .. code-block:: python import redis - r = redis.topk().reserve("mytopk", 3, 50, 4, 0.9) - info = r.topk().info("mytopk) + r = redis.Redis() + r.timeseries().create(2, retension_msecs=5) + +.. automodule:: redis.commands.timeseries.commands + :members: TimeSeriesCommands + -.. automodule:: redis.commands.bf.commands - :members: BFCommands, CFCommands, CMSCommands, TOPKCommands diff --git a/docs/sentinel_commands.rst b/docs/sentinel_commands.rst deleted file mode 100644 index e5be11e85a..0000000000 --- a/docs/sentinel_commands.rst +++ /dev/null @@ -1,20 +0,0 @@ -Redis Sentinel Commands -======================= - -redis-py can be used together with `Redis -Sentinel `_ to discover Redis nodes. You -need to have at least one Sentinel daemon running in order to use -redis-py's Sentinel support. - -Connection example (assumes redis redis on the ports listed below): - - >>> from redis import Sentinel - >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) - >>> sentinel.discover_master('mymaster') - ('127.0.0.1', 6379) - >>> sentinel.discover_slaves('mymaster') - [('127.0.0.1', 6380)] - - -.. autoclass:: redis.commands.sentinel.SentinelCommands - :members: \ No newline at end of file diff --git a/redis/client.py b/redis/client.py index 0984a7c243..490b06d606 100755 --- a/redis/client.py +++ b/redis/client.py @@ -832,6 +832,7 @@ def from_url(cls, url, **kwargs): There are several ways to specify a database number. The first value found will be used: + 1. A ``db`` querystring option, e.g. redis://localhost?db=0 2. If using the redis:// or rediss:// schemes, the path argument of the url, e.g. redis://localhost/0 diff --git a/redis/cluster.py b/redis/cluster.py index 5707a9da32..ec752741f3 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -564,6 +564,7 @@ def from_url(cls, url, **kwargs): There are several ways to specify a database number. The first value found will be used: + 1. A ``db`` querystring option, e.g. redis://localhost?db=0 2. If using the redis:// or rediss:// schemes, the path argument of the url, e.g. redis://localhost/0 diff --git a/redis/commands/graph/commands.py b/redis/commands/graph/commands.py index e097936503..1db8275223 100644 --- a/redis/commands/graph/commands.py +++ b/redis/commands/graph/commands.py @@ -35,7 +35,6 @@ def query(self, q, params=None, timeout=None, read_only=False, profile=False): Args: - ------- q : The query. params : dict @@ -127,7 +126,6 @@ def explain(self, query, params=None): Args: - ------- query: The query that will be executed. params: dict diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py index 4ec6fc9dfa..d22afeb875 100644 --- a/redis/commands/search/commands.py +++ b/redis/commands/search/commands.py @@ -73,12 +73,9 @@ def create_index( ### Parameters: - **fields**: a list of TextField or NumericField objects - - **no_term_offsets**: If true, we will not save term offsets in - the index - - **no_field_flags**: If true, we will not save field flags that - allow searching in specific fields - - **stopwords**: If not None, we create the index with this custom - stopword list. The list can be empty + - **no_term_offsets**: If true, we will not save term offsets in the index + - **no_field_flags**: If true, we will not save field flags that allow searching in specific fields + - **stopwords**: If not None, we create the index with this custom stopword list. The list can be empty For more information: https://oss.redis.com/redisearch/Commands/#ftcreate """ # noqa @@ -132,6 +129,7 @@ def dropindex(self, delete_documents=False): ### Parameters: - **delete_documents**: If `True`, all documents will be deleted. + For more information: https://oss.redis.com/redisearch/Commands/#ftdropindex """ # noqa keep_str = "" if delete_documents else "KEEPDOCS" @@ -219,27 +217,23 @@ def add_document( ### Parameters - **doc_id**: the id of the saved document. - - **nosave**: if set to true, we just index the document, and don't - save a copy of it. This means that searches will just - return ids. - - **score**: the document ranking, between 0.0 and 1.0 - - **payload**: optional inner-index payload we can save for fast - i access in scoring functions - - **replace**: if True, and the document already is in the index, + - **nosave**: if set to true, we just index the document, and don't \ + save a copy of it. This means that searches will just return ids. + - **score**: the document ranking, between 0.0 and 1.0. + - **payload**: optional inner-index payload we can save for fast access in scoring functions + - **replace**: if True, and the document already is in the index, \ we perform an update and reindex the document - - **partial**: if True, the fields specified will be added to the - existing document. - This has the added benefit that any fields specified - with `no_index` - will not be reindexed again. Implies `replace` + - **partial**: if True, the fields specified will be added to the \ + existing document. \ + This has the added benefit that any fields specified \ + with `no_index` will not be reindexed again. Implies `replace` - **language**: Specify the language used for document tokenization. - - **no_create**: if True, the document is only updated and reindexed - if it already exists. - If the document does not exist, an error will be - returned. Implies `replace` - - **fields** kwargs dictionary of the document fields to be saved - and/or indexed. - NOTE: Geo points shoule be encoded as strings of "lon,lat" + - **no_create**: if True, the document is only updated and reindexed \ + if it already exists. If the document does not exist, an error will be \ + returned. Implies `replace` + - **fields** kwargs dictionary of the document fields to be saved and/or indexed. + + NOTE: Geo points shoule be encoded as strings of "lon,lat" For more information: https://oss.redis.com/redisearch/Commands/#ftadd """ # noqa @@ -487,7 +481,7 @@ def spellcheck(self, query, distance=None, include=None, exclude=None): **query**: search query. **distance***: the maximal Levenshtein distance for spelling - suggestions (default: 1, max: 4). + suggestions (default: 1, max: 4). **include**: specifies an inclusion custom dictionary. **exclude**: specifies an exclusion custom dictionary. diff --git a/redis/connection.py b/redis/connection.py index bde74b17df..8fdb4bdf8c 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -1145,11 +1145,11 @@ def parse_url(url): class ConnectionPool: """ Create a connection pool. ``If max_connections`` is set, then this - object raises :py:class:`~redis.ConnectionError` when the pool's + object raises :py:class:`~redis.exceptions.ConnectionError` when the pool's limit is reached. By default, TCP connections are created unless ``connection_class`` - is specified. Use :py:class:`~redis.UnixDomainSocketConnection` for + is specified. Use class:`.UnixDomainSocketConnection` for unix sockets. Any additional keyword arguments are passed to the constructor of @@ -1181,6 +1181,7 @@ def from_url(cls, url, **kwargs): There are several ways to specify a database number. The first value found will be used: + 1. A ``db`` querystring option, e.g. redis://localhost?db=0 2. If using the redis:// or rediss:// schemes, the path argument of the url, e.g. redis://localhost/0 diff --git a/redis/sentinel.py b/redis/sentinel.py index c9383d30a9..025ab39c8c 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -190,7 +190,7 @@ def execute_command(self, *args, **kwargs): """ Execute Sentinel command in sentinel nodes. once - If set to True, then execute the resulting command on a single - node at random, rather than across the entire sentinel cluster. + node at random, rather than across the entire sentinel cluster. """ once = bool(kwargs.get("once", False)) if "once" in kwargs.keys(): From 231d40275e57bfdf8cc3b98642e886fae9433389 Mon Sep 17 00:00:00 2001 From: Chayim Date: Thu, 30 Dec 2021 13:58:46 +0200 Subject: [PATCH 0327/1164] Support for unstable docker (#1842) --- CONTRIBUTING.md | 3 ++- docker/base/Dockerfile.unstable | 18 ++++++++++++++++++ docker/unstable/redis.conf | 3 +++ tox.ini | 10 ++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 docker/base/Dockerfile.unstable create mode 100644 docker/unstable/redis.conf diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f9ca0b113..ebb66bbf3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,8 +57,9 @@ can execute docker and its various commands. - A master Redis node - A Redis replica node - Three sentinel Redis nodes -- A multi-python docker, with your source code mounted in /data +- A redis cluster - An stunnel docker, fronting the master Redis node +- A Redis node, running unstable - the latest redis The replica node, is a replica of the master node, using the [leader-follower replication](https://redis.io/topics/replication) diff --git a/docker/base/Dockerfile.unstable b/docker/base/Dockerfile.unstable new file mode 100644 index 0000000000..ab5b7fc6fb --- /dev/null +++ b/docker/base/Dockerfile.unstable @@ -0,0 +1,18 @@ +# produces redisfab/redis-py:unstable +FROM ubuntu:bionic as builder +RUN apt-get update +RUN apt-get upgrade -y +RUN apt-get install -y build-essential git +RUN mkdir /build +WORKDIR /build +RUN git clone https://github.com/redis/redis +WORKDIR /build/redis +RUN make + +FROM ubuntu:bionic as runner +COPY --from=builder /build/redis/src/redis-server /usr/bin/redis-server +COPY --from=builder /build/redis/src/redis-cli /usr/bin/redis-cli +COPY --from=builder /build/redis/src/redis-sentinel /usr/bin/redis-sentinel + +EXPOSE 6379 +CMD ["redis-server", "/redis.conf"] diff --git a/docker/unstable/redis.conf b/docker/unstable/redis.conf new file mode 100644 index 0000000000..93a55cf3b3 --- /dev/null +++ b/docker/unstable/redis.conf @@ -0,0 +1,3 @@ +port 6378 +protected-mode no +save "" diff --git a/tox.ini b/tox.ini index 3ca4533b55..ac7f01a6ca 100644 --- a/tox.ini +++ b/tox.ini @@ -32,6 +32,15 @@ healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(sock volumes = bind:rw:{toxinidir}/docker/replica/redis.conf:/redis.conf +[docker:unstable] +name = unstable +image = redisfab/redis-py:unstable-bionic +ports = + 6378:6378/tcp +healtcheck_cmd = python -c "import socket;print(True) if 0 == socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect_ex(('127.0.0.1',6378)) else False" +volumes = + bind:rw:{toxinidir}/docker/unstable/redis.conf:/redis.conf + [docker:sentinel_1] name = sentinel_1 @@ -108,6 +117,7 @@ deps = -r {toxinidir}/requirements.txt -r {toxinidir}/dev_requirements.txt docker = + unstable master replica sentinel_1 From 15f315a496c3267c8cbcc6d6d9c6005ea4d4a4d5 Mon Sep 17 00:00:00 2001 From: dvora-h <67596500+dvora-h@users.noreply.github.com> Date: Tue, 4 Jan 2022 12:27:37 +0200 Subject: [PATCH 0328/1164] Support test with redis unstable docker (#1850) --- tests/conftest.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 505a6e47e1..9ba63d6caa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ REDIS_INFO = {} default_redis_url = "redis://localhost:6379/9" default_redismod_url = "redis://localhost:36379" +default_redis_unstable_url = "redis://localhost:6378" # default ssl client ignores verification for the purpose of testing default_redis_ssl_url = "rediss://localhost:6666" @@ -54,6 +55,14 @@ def pytest_addoption(parser): " defaults to `%(default)s`", ) + parser.addoption( + "--redis-unstable-url", + default=default_redis_unstable_url, + action="store", + help="Redis unstable (latest version) connection string " + "defaults to %(default)s`", + ) + def _get_info(redis_url): client = redis.Redis.from_url(redis_url) @@ -357,6 +366,13 @@ def master_host(request): yield parts.hostname, parts.port +@pytest.fixture() +def unstable_r(request): + url = request.config.getoption("--redis-unstable-url") + with _get_client(redis.Redis, request, from_url=url) as client: + yield client + + def wait_for_command(client, monitor, command, key=None): # issue a command with a key name that's local to this process. # if we find a command with our key before the command we're waiting From 1fbc2d10cb94b888883cb3e7908bb9e2a4389806 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 10 Jan 2022 02:18:42 -0600 Subject: [PATCH 0329/1164] `setup.py`: Add project_urls for PyPI (#1867) --- setup.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/setup.py b/setup.py index 7733220838..054b0c023c 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,12 @@ ] ), url="https://github.com/redis/redis-py", + project_urls={ + "Documentation": "https://redis.readthedocs.io/en/latest/", + "Changes": "https://github.com/redis/redis-py/releases", + "Code": "https://github.com/redis/redis-py", + "Issue tracker": "https://github.com/redis/redis-py/issues", + }, author="Redis Inc.", author_email="oss@redis.com", python_requires=">=3.6", From cb198730868b05ca804db2d689ebcf208f1d7ba2 Mon Sep 17 00:00:00 2001 From: Avital Fine <79420960+AvitalFineRedis@users.noreply.github.com> Date: Mon, 10 Jan 2022 09:18:55 +0100 Subject: [PATCH 0330/1164] FT.CREATE - support MAXTEXTFIELDS, TEMPORARY, NOHL, NOFREQS, SKIPINITIALSCAN (#1847) Co-authored-by: Chayim I. Kirshen --- redis/commands/search/commands.py | 78 +++++++++++++++++++++++-------- tests/test_search.py | 66 ++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 20 deletions(-) diff --git a/redis/commands/search/commands.py b/redis/commands/search/commands.py index d22afeb875..3f768ab320 100644 --- a/redis/commands/search/commands.py +++ b/redis/commands/search/commands.py @@ -44,7 +44,12 @@ NOOFFSETS = "NOOFFSETS" NOFIELDS = "NOFIELDS" +NOHL = "NOHL" +NOFREQS = "NOFREQS" +MAXTEXTFIELDS = "MAXTEXTFIELDS" +TEMPORARY = "TEMPORARY" STOPWORDS = "STOPWORDS" +SKIPINITIALSCAN = "SKIPINITIALSCAN" WITHSCORES = "WITHSCORES" FUZZY = "FUZZY" WITHPAYLOADS = "WITHPAYLOADS" @@ -66,6 +71,11 @@ def create_index( no_field_flags=False, stopwords=None, definition=None, + max_text_fields=False, + temporary=None, + no_highlight=False, + no_term_frequencies=False, + skip_initial_scan=False, ): """ Create the search index. The index must not already exist. @@ -73,9 +83,23 @@ def create_index( ### Parameters: - **fields**: a list of TextField or NumericField objects - - **no_term_offsets**: If true, we will not save term offsets in the index - - **no_field_flags**: If true, we will not save field flags that allow searching in specific fields - - **stopwords**: If not None, we create the index with this custom stopword list. The list can be empty + - **no_term_offsets**: If true, we will not save term offsets in + the index + - **no_field_flags**: If true, we will not save field flags that + allow searching in specific fields + - **stopwords**: If not None, we create the index with this custom + stopword list. The list can be empty + - **max_text_fields**: If true, we will encode indexes as if there + were more than 32 text fields which allows you to add additional + fields (beyond 32). + - **temporary**: Create a lightweight temporary index which will + expire after the specified period of inactivity (in seconds). The + internal idle timer is reset whenever the index is searched or added to. + - **no_highlight**: If true, disabling highlighting support. + Also implied by no_term_offsets. + - **no_term_frequencies**: If true, we avoid saving the term frequencies + in the index. + - **skip_initial_scan**: If true, we do not scan and index. For more information: https://oss.redis.com/redisearch/Commands/#ftcreate """ # noqa @@ -83,10 +107,21 @@ def create_index( args = [CREATE_CMD, self.index_name] if definition is not None: args += definition.args + if max_text_fields: + args.append(MAXTEXTFIELDS) + if temporary is not None and isinstance(temporary, int): + args.append(TEMPORARY) + args.append(temporary) if no_term_offsets: args.append(NOOFFSETS) + if no_highlight: + args.append(NOHL) if no_field_flags: args.append(NOFIELDS) + if no_term_frequencies: + args.append(NOFREQS) + if skip_initial_scan: + args.append(SKIPINITIALSCAN) if stopwords is not None and isinstance(stopwords, (list, tuple, set)): args += [STOPWORDS, len(stopwords)] if len(stopwords) > 0: @@ -129,7 +164,6 @@ def dropindex(self, delete_documents=False): ### Parameters: - **delete_documents**: If `True`, all documents will be deleted. - For more information: https://oss.redis.com/redisearch/Commands/#ftdropindex """ # noqa keep_str = "" if delete_documents else "KEEPDOCS" @@ -217,23 +251,27 @@ def add_document( ### Parameters - **doc_id**: the id of the saved document. - - **nosave**: if set to true, we just index the document, and don't \ - save a copy of it. This means that searches will just return ids. - - **score**: the document ranking, between 0.0 and 1.0. - - **payload**: optional inner-index payload we can save for fast access in scoring functions - - **replace**: if True, and the document already is in the index, \ + - **nosave**: if set to true, we just index the document, and don't + save a copy of it. This means that searches will just + return ids. + - **score**: the document ranking, between 0.0 and 1.0 + - **payload**: optional inner-index payload we can save for fast + i access in scoring functions + - **replace**: if True, and the document already is in the index, we perform an update and reindex the document - - **partial**: if True, the fields specified will be added to the \ - existing document. \ - This has the added benefit that any fields specified \ - with `no_index` will not be reindexed again. Implies `replace` + - **partial**: if True, the fields specified will be added to the + existing document. + This has the added benefit that any fields specified + with `no_index` + will not be reindexed again. Implies `replace` - **language**: Specify the language used for document tokenization. - - **no_create**: if True, the document is only updated and reindexed \ - if it already exists. If the document does not exist, an error will be \ - returned. Implies `replace` - - **fields** kwargs dictionary of the document fields to be saved and/or indexed. - - NOTE: Geo points shoule be encoded as strings of "lon,lat" + - **no_create**: if True, the document is only updated and reindexed + if it already exists. + If the document does not exist, an error will be + returned. Implies `replace` + - **fields** kwargs dictionary of the document fields to be saved + and/or indexed. + NOTE: Geo points shoule be encoded as strings of "lon,lat" For more information: https://oss.redis.com/redisearch/Commands/#ftadd """ # noqa @@ -481,7 +519,7 @@ def spellcheck(self, query, distance=None, include=None, exclude=None): **query**: search query. **distance***: the maximal Levenshtein distance for spelling - suggestions (default: 1, max: 4). + suggestions (default: 1, max: 4). **include**: specifies an inclusion custom dictionary. **exclude**: specifies an exclusion custom dictionary. diff --git a/tests/test_search.py b/tests/test_search.py index 7d666cbd96..6c79041cfe 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1154,6 +1154,72 @@ def test_index_definition(client): createIndex(client.ft(), num_docs=500, definition=definition) +@pytest.mark.redismod +def testExpire(client): + client.ft().create_index((TextField("txt", sortable=True),), temporary=4) + ttl = client.execute_command("ft.debug", "TTL", "idx") + assert ttl > 2 + + while ttl > 2: + ttl = client.execute_command("ft.debug", "TTL", "idx") + time.sleep(0.01) + + # add document - should reset the ttl + client.ft().add_document("doc", txt="foo bar", text="this is a simple test") + ttl = client.execute_command("ft.debug", "TTL", "idx") + assert ttl > 2 + try: + while True: + ttl = client.execute_command("ft.debug", "TTL", "idx") + time.sleep(0.5) + except redis.exceptions.ResponseError: + assert ttl == 0 + + +@pytest.mark.redismod +def testSkipInitialScan(client): + client.hset("doc1", "foo", "bar") + q = Query("@foo:bar") + + client.ft().create_index((TextField("foo"),), skip_initial_scan=True) + assert 0 == client.ft().search(q).total + + +@pytest.mark.redismod +def testSummarizeDisabled_nooffset(client): + client.ft().create_index((TextField("txt"),), no_term_offsets=True) + client.ft().add_document("doc1", txt="foo bar") + with pytest.raises(Exception): + client.ft().search(Query("foo").summarize(fields=["txt"])) + + +@pytest.mark.redismod +def testSummarizeDisabled_nohl(client): + client.ft().create_index((TextField("txt"),), no_highlight=True) + client.ft().add_document("doc1", txt="foo bar") + with pytest.raises(Exception): + client.ft().search(Query("foo").summarize(fields=["txt"])) + + +@pytest.mark.redismod +def testMaxTextFields(client): + # Creating the index definition + client.ft().create_index((TextField("f0"),)) + for x in range(1, 32): + client.ft().alter_schema_add((TextField(f"f{x}"),)) + + # Should be too many indexes + with pytest.raises(redis.ResponseError): + client.ft().alter_schema_add((TextField(f"f{x}"),)) + + client.ft().dropindex("idx") + # Creating the index definition + client.ft().create_index((TextField("f0"),), max_text_fields=True) + # Fill the index with fields + for x in range(1, 50): + client.ft().alter_schema_add((TextField(f"f{x}"),)) + + @pytest.mark.redismod @skip_ifmodversion_lt("2.0.0", "search") def test_create_client_definition(client): From 1fc1233ffd9e925ecfb7d9ad83a9641a03508985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bjerregaard=20Vrist?= Date: Mon, 10 Jan 2022 09:21:34 +0100 Subject: [PATCH 0331/1164] Allowing poetry and redis-py to install together (#1854) This moves packaging to >=20.4 rather than the latest. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 054b0c023c..559e521982 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ python_requires=">=3.6", install_requires=[ "deprecated>=1.2.3", - "packaging>=21.3", + "packaging>=20.4", 'importlib-metadata >= 1.0; python_version < "3.8"', ], classifiers=[ From a5b4dcbb414bdc51ad50439bf82ec39055084a09 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Mon, 10 Jan 2022 03:22:37 -0500 Subject: [PATCH 0332/1164] Typo and typing in GraphCommands documentation (#1855) --- redis/commands/graph/commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redis/commands/graph/commands.py b/redis/commands/graph/commands.py index 1db8275223..fa0a9da55f 100644 --- a/redis/commands/graph/commands.py +++ b/redis/commands/graph/commands.py @@ -35,7 +35,7 @@ def query(self, q, params=None, timeout=None, read_only=False, profile=False): Args: - q : + q : str The query. params : dict Query parameters. @@ -178,7 +178,7 @@ def config(self, name, value=None, set=False): name : str The name of the configuration value : - The value we want to ser (can be used only when `set` is on) + The value we want to set (can be used only when `set` is on) set : bool Turn on to set a configuration. Default behavior is get. """ From 80f1d2f3f056f5e4e81796158dd29b0799f54dc7 Mon Sep 17 00:00:00 2001 From: Bar Shaul <88437685+barshaul@users.noreply.github.com> Date: Mon, 10 Jan 2022 13:03:48 +0200 Subject: [PATCH 0333/1164] Clusters should optionally require full slot coverage (#1845) --- redis/cluster.py | 75 ++++++------------------------------- tests/test_cluster.py | 86 ++++++++++++++++--------------------------- 2 files changed, 44 insertions(+), 117 deletions(-) diff --git a/redis/cluster.py b/redis/cluster.py index ec752741f3..546472357d 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -387,8 +387,7 @@ def __init__( port=6379, startup_nodes=None, cluster_error_retry_attempts=3, - require_full_coverage=True, - skip_full_coverage_check=False, + require_full_coverage=False, reinitialize_steps=10, read_from_replicas=False, url=None, @@ -404,16 +403,15 @@ def __init__( :port: 'int' Can be used to point to a startup node :require_full_coverage: 'bool' - If set to True, as it is by default, all slots must be covered. - If set to False and not all slots are covered, the instance - creation will succeed only if 'cluster-require-full-coverage' - configuration is set to 'no' in all of the cluster's nodes. - Otherwise, RedisClusterException will be thrown. - :skip_full_coverage_check: 'bool' - If require_full_coverage is set to False, a check of - cluster-require-full-coverage config will be executed against all - nodes. Set skip_full_coverage_check to True to skip this check. - Useful for clusters without the CONFIG command (like ElastiCache) + When set to False (default value): the client will not require a + full coverage of the slots. However, if not all slots are covered, + and at least one node has 'cluster-require-full-coverage' set to + 'yes,' the server will throw a ClusterDownError for some key-based + commands. See - + https://redis.io/topics/cluster-tutorial#redis-cluster-configuration-parameters + When set to True: all slots must be covered to construct the + cluster client. If not all slots are covered, RedisClusterException + will be thrown. :read_from_replicas: 'bool' Enable read from replicas in READONLY mode. You can read possibly stale data. @@ -510,7 +508,6 @@ def __init__( startup_nodes=startup_nodes, from_url=from_url, require_full_coverage=require_full_coverage, - skip_full_coverage_check=skip_full_coverage_check, **kwargs, ) @@ -1111,8 +1108,7 @@ def __init__( self, startup_nodes, from_url=False, - require_full_coverage=True, - skip_full_coverage_check=False, + require_full_coverage=False, lock=None, **kwargs, ): @@ -1123,7 +1119,6 @@ def __init__( self.populate_startup_nodes(startup_nodes) self.from_url = from_url self._require_full_coverage = require_full_coverage - self._skip_full_coverage_check = skip_full_coverage_check self._moved_exception = None self.connection_kwargs = kwargs self.read_load_balancer = LoadBalancer() @@ -1249,32 +1244,6 @@ def populate_startup_nodes(self, nodes): for n in nodes: self.startup_nodes[n.name] = n - def cluster_require_full_coverage(self, cluster_nodes): - """ - if exists 'cluster-require-full-coverage no' config on redis servers, - then even all slots are not covered, cluster still will be able to - respond - """ - - def node_require_full_coverage(node): - try: - return ( - "yes" - in node.redis_connection.config_get( - "cluster-require-full-coverage" - ).values() - ) - except ConnectionError: - return False - except Exception as e: - raise RedisClusterException( - 'ERROR sending "config get cluster-require-full-coverage"' - f" command to redis server: {node.name}, {e}" - ) - - # at least one node should have cluster-require-full-coverage yes - return any(node_require_full_coverage(node) for node in cluster_nodes.values()) - def check_slots_coverage(self, slots_cache): # Validate if all slots are covered or if we should try next # startup node @@ -1450,29 +1419,9 @@ def initialize(self): # isn't a full coverage raise RedisClusterException( f"All slots are not covered after query all startup_nodes. " - f"{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} " + f"{len(tmp_slots)} of {REDIS_CLUSTER_HASH_SLOTS} " f"covered..." ) - elif not fully_covered and not self._require_full_coverage: - # The user set require_full_coverage to False. - # In case of full coverage requirement in the cluster's Redis - # configurations, we will raise an exception. Otherwise, we may - # continue with partial coverage. - # see Redis Cluster configuration parameters in - # https://redis.io/topics/cluster-tutorial - if ( - not self._skip_full_coverage_check - and self.cluster_require_full_coverage(tmp_nodes_cache) - ): - raise RedisClusterException( - "Not all slots are covered but the cluster's " - "configuration requires full coverage. Set " - "cluster-require-full-coverage configuration to no on " - "all of the cluster nodes if you wish the cluster to " - "be able to serve without being fully covered." - f"{len(self.slots_cache)} of {REDIS_CLUSTER_HASH_SLOTS} " - f"covered..." - ) # Set the tmp variables to the real variables self.nodes_cache = tmp_nodes_cache diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 496ed9818b..90f52d4899 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -28,6 +28,7 @@ NoPermissionError, RedisClusterException, RedisError, + ResponseError, ) from redis.utils import str_if_bytes from tests.test_pubsub import wait_for_message @@ -628,6 +629,32 @@ def test_get_node_from_key(self, r): assert replica.server_type == REPLICA assert replica in slot_nodes + def test_not_require_full_coverage_cluster_down_error(self, r): + """ + When require_full_coverage is set to False (default client config) and not + all slots are covered, if one of the nodes has 'cluster-require_full_coverage' + config set to 'yes' some key-based commands should throw ClusterDownError + """ + node = r.get_node_from_key("foo") + missing_slot = r.keyslot("foo") + assert r.set("foo", "bar") is True + try: + assert all(r.cluster_delslots(missing_slot)) + with pytest.raises(ClusterDownError): + r.exists("foo") + finally: + try: + # Add back the missing slot + assert r.cluster_addslots(node, missing_slot) is True + # Make sure we are not getting ClusterDownError anymore + assert r.exists("foo") == 1 + except ResponseError as e: + if f"Slot {missing_slot} is already busy" in str(e): + # It can happen if the test failed to delete this slot + pass + else: + raise e + @pytest.mark.onlycluster class TestClusterRedisCommands: @@ -1848,40 +1875,20 @@ def test_init_slots_cache_not_all_slots_covered(self): [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], ] with pytest.raises(RedisClusterException) as ex: - get_mocked_redis_client( - host=default_host, port=default_port, cluster_slots=cluster_slots - ) - assert str(ex.value).startswith( - "All slots are not covered after query all startup_nodes." - ) - - def test_init_slots_cache_not_require_full_coverage_error(self): - """ - When require_full_coverage is set to False and not all slots are - covered, if one of the nodes has 'cluster-require_full_coverage' - config set to 'yes' the cluster initialization should fail - """ - # Missing slot 5460 - cluster_slots = [ - [0, 5459, ["127.0.0.1", 7000], ["127.0.0.1", 7003]], - [5461, 10922, ["127.0.0.1", 7001], ["127.0.0.1", 7004]], - [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], - ] - - with pytest.raises(RedisClusterException): get_mocked_redis_client( host=default_host, port=default_port, cluster_slots=cluster_slots, - require_full_coverage=False, - coverage_result="yes", + require_full_coverage=True, ) + assert str(ex.value).startswith( + "All slots are not covered after query all startup_nodes." + ) def test_init_slots_cache_not_require_full_coverage_success(self): """ When require_full_coverage is set to False and not all slots are - covered, if all of the nodes has 'cluster-require_full_coverage' - config set to 'no' the cluster initialization should succeed + covered the cluster client initialization should succeed """ # Missing slot 5460 cluster_slots = [ @@ -1895,39 +1902,10 @@ def test_init_slots_cache_not_require_full_coverage_success(self): port=default_port, cluster_slots=cluster_slots, require_full_coverage=False, - coverage_result="no", ) assert 5460 not in rc.nodes_manager.slots_cache - def test_init_slots_cache_not_require_full_coverage_skips_check(self): - """ - Test that when require_full_coverage is set to False and - skip_full_coverage_check is set to true, the cluster initialization - succeed without checking the nodes' Redis configurations - """ - # Missing slot 5460 - cluster_slots = [ - [0, 5459, ["127.0.0.1", 7000], ["127.0.0.1", 7003]], - [5461, 10922, ["127.0.0.1", 7001], ["127.0.0.1", 7004]], - [10923, 16383, ["127.0.0.1", 7002], ["127.0.0.1", 7005]], - ] - - with patch.object( - NodesManager, "cluster_require_full_coverage" - ) as conf_check_mock: - rc = get_mocked_redis_client( - host=default_host, - port=default_port, - cluster_slots=cluster_slots, - require_full_coverage=False, - skip_full_coverage_check=True, - coverage_result="no", - ) - - assert conf_check_mock.called is False - assert 5460 not in rc.nodes_manager.slots_cache - def test_init_slots_cache(self): """ Test that slots cache can in initialized and all slots are covered From 39ce6852f56021ac9f80359f3a1e387593cfbbbb Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Mon, 10 Jan 2022 06:19:40 -0500 Subject: [PATCH 0334/1164] Set keys var otherwise variable not created (#1853) --- redis/commands/parser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/redis/commands/parser.py b/redis/commands/parser.py index dadf3c6bf8..4cce800ec3 100644 --- a/redis/commands/parser.py +++ b/redis/commands/parser.py @@ -103,6 +103,7 @@ def _get_pubsub_keys(self, *args): return None args = [str_if_bytes(arg) for arg in args] command = args[0].upper() + keys = None if command == "PUBSUB": # the second argument is a part of the command name, e.g. # ['PUBSUB', 'NUMSUB', 'foo']. @@ -117,6 +118,4 @@ def _get_pubsub_keys(self, *args): # format example: # PUBLISH channel message keys = [args[1]] - else: - keys = None return keys From 9a8674a94740cd299e5c852fd3d9b9841995b1a5 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 10 Jan 2022 13:19:57 +0200 Subject: [PATCH 0335/1164] syncing requirements (#1870) --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f1e7e7ecdc..b05ff454bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -deprecated -packaging +deprecated>=1.2.3 +packaging>=20.4 From 41cef4703a9e23af72040966a9411ee55d92d917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?I=C3=B1aki=20Ucar?= Date: Mon, 10 Jan 2022 12:20:20 +0100 Subject: [PATCH 0336/1164] get_connection: catch OSError too (#1832) --- redis/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redis/connection.py b/redis/connection.py index 8fdb4bdf8c..4178f67c57 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -1317,7 +1317,7 @@ def get_connection(self, command_name, *keys, **options): try: if connection.can_read(): raise ConnectionError("Connection has data") - except ConnectionError: + except (ConnectionError, OSError): connection.disconnect() connection.connect() if connection.can_read(): From f807f3ba1bb25138696e42f75ac59036e323a687 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Wed, 12 Jan 2022 02:04:55 -0500 Subject: [PATCH 0337/1164] Triple quote docstrings in client.py PEP 257 (#1876) --- redis/client.py | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/redis/client.py b/redis/client.py index 490b06d606..2832d2c103 100755 --- a/redis/client.py +++ b/redis/client.py @@ -94,14 +94,14 @@ def parse_debug_object(response): def parse_object(response, infotype): - "Parse the results of an OBJECT command" + """Parse the results of an OBJECT command""" if infotype in ("idletime", "refcount"): return int_or_none(response) return response def parse_info(response): - "Parse the result of Redis's INFO command into a Python dict" + """Parse the result of Redis's INFO command into a Python dict""" info = {} response = str_if_bytes(response) @@ -145,7 +145,7 @@ def get_value(value): def parse_memory_stats(response, **kwargs): - "Parse the results of MEMORY STATS" + """Parse the results of MEMORY STATS""" stats = pairs_to_dict(response, decode_keys=True, decode_string_values=True) for key, value in stats.items(): if key.startswith("db."): @@ -219,7 +219,7 @@ def parse_sentinel_get_master(response): def pairs_to_dict(response, decode_keys=False, decode_string_values=False): - "Create a dict given a list of key/value pairs" + """Create a dict given a list of key/value pairs""" if response is None: return {} if decode_keys or decode_string_values: @@ -973,19 +973,15 @@ def __repr__(self): return f"{type(self).__name__}<{repr(self.connection_pool)}>" def get_encoder(self): - """ - Get the connection pool's encoder - """ + """Get the connection pool's encoder""" return self.connection_pool.get_encoder() def get_connection_kwargs(self): - """ - Get the connection's key-word arguments - """ + """Get the connection's key-word arguments""" return self.connection_pool.connection_kwargs def set_response_callback(self, command, callback): - "Set a custom Response Callback" + """Set a custom Response Callback""" self.response_callbacks[command] = callback def load_external_module( @@ -1165,7 +1161,7 @@ def _disconnect_raise(self, conn, error): # COMMAND EXECUTION AND PROTOCOL PARSING def execute_command(self, *args, **options): - "Execute a command and return a parsed response" + """Execute a command and return a parsed response""" pool = self.connection_pool command_name = args[0] conn = self.connection or pool.get_connection(command_name, **options) @@ -1182,7 +1178,7 @@ def execute_command(self, *args, **options): pool.release(conn) def parse_response(self, connection, command_name, **options): - "Parses a response from the Redis server" + """Parses a response from the Redis server""" try: if NEVER_DECODE in options: response = connection.read_response(disable_decoding=True) @@ -1227,7 +1223,7 @@ def __exit__(self, *args): self.connection_pool.release(self.connection) def next_command(self): - "Parse the response from a monitor command" + """Parse the response from a monitor command""" response = self.connection.read_response() if isinstance(response, bytes): response = self.connection.encoder.decode(response, force=True) @@ -1262,7 +1258,7 @@ def next_command(self): } def listen(self): - "Listen for commands coming to the server." + """Listen for commands coming to the server.""" while True: yield self.next_command() @@ -1355,11 +1351,11 @@ def on_connect(self, connection): @property def subscribed(self): - "Indicates if there are subscriptions to any channels or patterns" + """Indicates if there are subscriptions to any channels or patterns""" return self.subscribed_event.is_set() def execute_command(self, *args): - "Execute a publish/subscribe command" + """Execute a publish/subscribe command""" # NOTE: don't parse the response in this function -- it could pull a # legitimate message off the stack if the connection is already @@ -1751,7 +1747,7 @@ def __len__(self): return len(self.command_stack) def __bool__(self): - "Pipeline instances should always evaluate to True" + """Pipeline instances should always evaluate to True""" return True def reset(self): @@ -1992,7 +1988,7 @@ def _disconnect_raise_reset(self, conn, error): raise def execute(self, raise_on_error=True): - "Execute all the commands in the current pipeline" + """Execute all the commands in the current pipeline""" stack = self.command_stack if not stack and not self.watching: return [] @@ -2019,17 +2015,18 @@ def execute(self, raise_on_error=True): self.reset() def discard(self): - """Flushes all previously queued commands + """ + Flushes all previously queued commands See: https://redis.io/commands/DISCARD """ self.execute_command("DISCARD") def watch(self, *names): - "Watches the values at keys ``names``" + """Watches the values at keys ``names``""" if self.explicit_transaction: raise RedisError("Cannot issue a WATCH after a MULTI") return self.execute_command("WATCH", *names) def unwatch(self): - "Unwatches all previously specified keys" + """Unwatches all previously specified keys""" return self.watching and self.execute_command("UNWATCH") or True From 0affa0ed3f3cbcb6dec29b34a580f769f69ae9f7 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 12 Jan 2022 09:11:29 +0200 Subject: [PATCH 0338/1164] Timeseries docs fix (#1877) --- docs/redismodules.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/redismodules.rst b/docs/redismodules.rst index f31167555b..24be4da5b4 100644 --- a/docs/redismodules.rst +++ b/docs/redismodules.rst @@ -131,7 +131,7 @@ These are the commands for interacting with the `RedisTimeSeries module Date: Sun, 16 Jan 2022 17:49:24 +0200 Subject: [PATCH 0339/1164] Define incr/decr as aliases of incrby/decrby (#1874) --- redis/commands/core.py | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index 4f0accd957..73003e7fb6 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1159,17 +1159,6 @@ def copy(self, source, destination, destination_db=None, replace=False): params.append("REPLACE") return self.execute_command("COPY", *params) - def decr(self, name, amount=1): - """ - Decrements the value of ``key`` by ``amount``. If no key exists, - the value will be initialized as 0 - ``amount`` - - For more information check https://redis.io/commands/decr - """ - # An alias for ``decr()``, because it is already implemented - # as DECRBY redis command. - return self.decrby(name, amount) - def decrby(self, name, amount=1): """ Decrements the value of ``key`` by ``amount``. If no key exists, @@ -1179,6 +1168,8 @@ def decrby(self, name, amount=1): """ return self.execute_command("DECRBY", name, amount) + decr = decrby + def delete(self, *names): """ Delete one or more keys specified by ``names`` @@ -1350,15 +1341,6 @@ def getset(self, name, value): """ return self.execute_command("GETSET", name, value) - def incr(self, name, amount=1): - """ - Increments the value of ``key`` by ``amount``. If no key exists, - the value will be initialized as ``amount`` - - For more information check https://redis.io/commands/incr - """ - return self.incrby(name, amount) - def incrby(self, name, amount=1): """ Increments the value of ``key`` by ``amount``. If no key exists, @@ -1366,10 +1348,10 @@ def incrby(self, name, amount=1): For more information check https://redis.io/commands/incrby """ - # An alias for ``incr()``, because it is already implemented - # as INCRBY redis command. return self.execute_command("INCRBY", name, amount) + incr = incrby + def incrbyfloat(self, name, amount=1.0): """ Increments the value at key ``name`` by floating ``amount``. From d1291660908f656447bb9132c92813489342ead4 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 17 Jan 2022 09:13:35 +0200 Subject: [PATCH 0340/1164] More parallel CI workflows (#1881) --- .github/workflows/integration.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index e81cf339fd..92f48a49e9 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -31,7 +31,7 @@ jobs: run-tests: runs-on: ubuntu-latest strategy: - max-parallel: 6 + max-parallel: 15 matrix: python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-3.7'] test-type: ['standalone', 'cluster'] From f0c0ab24e8b1a98fcc1e6bc7cc5c6ecfcd75da85 Mon Sep 17 00:00:00 2001 From: Chayim Date: Mon, 17 Jan 2022 09:14:16 +0200 Subject: [PATCH 0341/1164] OCSP Stapling Support (#1873) --- .github/workflows/integration.yaml | 1 + docs/examples.rst | 3 +- ...xample.ipynb => connection_examples.ipynb} | 93 +----- docs/examples/ssl_connecton_examples.ipynb | 277 ++++++++++++++++++ redis/client.py | 6 + redis/connection.py | 48 ++- redis/ocsp.py | 174 ++++++++++- setup.py | 2 +- tasks.py | 2 +- tests/test_ssl.py | 72 ++++- tox.ini | 4 +- 11 files changed, 571 insertions(+), 111 deletions(-) rename docs/examples/{connection_example.ipynb => connection_examples.ipynb} (63%) create mode 100644 docs/examples/ssl_connecton_examples.ipynb diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 92f48a49e9..b034428bcd 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -30,6 +30,7 @@ jobs: run-tests: runs-on: ubuntu-latest + timeout-minutes: 30 strategy: max-parallel: 15 matrix: diff --git a/docs/examples.rst b/docs/examples.rst index 1a82182869..7a328afd82 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -5,4 +5,5 @@ Examples :maxdepth: 3 :glob: - examples/connection_example + examples/connection_examples + examples/ssl_connection_examples diff --git a/docs/examples/connection_example.ipynb b/docs/examples/connection_examples.ipynb similarity index 63% rename from docs/examples/connection_example.ipynb rename to docs/examples/connection_examples.ipynb index af5193e0d7..b0084ff055 100644 --- a/docs/examples/connection_example.ipynb +++ b/docs/examples/connection_examples.ipynb @@ -7,15 +7,6 @@ "# Connection Examples" ] }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import redis" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -40,6 +31,8 @@ } ], "source": [ + "import redis\n", + "\n", "connection = redis.Redis()\n", "connection.ping()" ] @@ -68,8 +61,10 @@ } ], "source": [ - "decode_connection = redis.Redis(decode_responses=True)\n", - "connection.ping()" + "import redis\n", + "\n", + "decoded_connection = redis.Redis(decode_responses=True)\n", + "decoded_connection.ping()" ] }, { @@ -96,82 +91,12 @@ } ], "source": [ + "import redis\n", + "\n", "user_connection = redis.Redis(host='localhost', port=6380, username='dvora', password='redis', decode_responses=True)\n", "user_connection.ping()" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Connecting to a Redis instance via SSL." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "ssl_connection = redis.Redis(host='localhost', port=6666, ssl=True, ssl_cert_reqs=\"none\")\n", - "ssl_connection.ping()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Connecting to a Redis instance via SSL, while specifying a self-signed SSL certificate." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import os\n", - "\n", - "ROOT = os.path.join(os.getcwd(), \"..\", \"..\")\n", - "CERT_DIR = os.path.abspath(os.path.join(ROOT, \"docker\", \"stunnel\", \"keys\"))\n", - "ssl_certfile=os.path.join(CERT_DIR, \"server-cert.pem\")\n", - "ssl_keyfile=os.path.join(CERT_DIR, \"server-key.pem\")\n", - "ssl_ca_certs=os.path.join(CERT_DIR, \"server-cert.pem\")\n", - "\n", - "ssl_cert_conn = redis.Redis(\n", - " host=\"localhost\",\n", - " port=6666,\n", - " ssl=True,\n", - " ssl_certfile=ssl_certfile,\n", - " ssl_keyfile=ssl_keyfile,\n", - " ssl_cert_reqs=\"required\",\n", - " ssl_ca_certs=ssl_ca_certs,\n", - ")\n", - "ssl_cert_conn.ping()" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -203,7 +128,7 @@ } ], "source": [ - "url_connection = redis.from_url(\"rediss://localhost:6666?ssl_cert_reqs=none&decode_responses=True&health_check_interval=2\")\n", + "url_connection = redis.from_url(\"redis://localhost:6379?decode_responses=True&health_check_interval=2\")\n", "\n", "url_connection.ping()" ] diff --git a/docs/examples/ssl_connecton_examples.ipynb b/docs/examples/ssl_connecton_examples.ipynb new file mode 100644 index 0000000000..386e4af452 --- /dev/null +++ b/docs/examples/ssl_connecton_examples.ipynb @@ -0,0 +1,277 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# SSL Connection Examples" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a Redis instance via SSL." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import redis\n", + "\n", + "ssl_connection = redis.Redis(host='localhost', port=6666, ssl=True, ssl_cert_reqs=\"none\")\n", + "ssl_connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a Redis instance via a URL string" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import redis\n", + "url_connection = redis.from_url(\"redis://localhost:6379?ssl_cert_reqs=none&decode_responses=True&health_check_interval=2\")\n", + "url_connection.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a Redis instance via SSL, while specifying a self-signed SSL certificate." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import os\n", + "import redis\n", + "\n", + "ssl_certfile=\"some-certificate.pem\"\n", + "ssl_keyfile=\"some-key.pem\"\n", + "ssl_ca_certs=ssl_certfile\n", + "\n", + "ssl_cert_conn = redis.Redis(\n", + " host=\"localhost\",\n", + " port=6666,\n", + " ssl=True,\n", + " ssl_certfile=ssl_certfile,\n", + " ssl_keyfile=ssl_keyfile,\n", + " ssl_cert_reqs=\"required\",\n", + " ssl_ca_certs=ssl_ca_certs,\n", + ")\n", + "ssl_cert_conn.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connecting to a Redis instance via SSL, and validate the OCSP status of the certificate\n", + "\n", + "The redis package is design to be small, meaning extra libraries must be installed, in order to support OCSP stapling. As a result, first install redis via:\n", + "\n", + "*pip install redis[ocsp]*\n", + "\n", + "This will install cryptography, requests, and PyOpenSSL, none of which are generally required to use Redis." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import os\n", + "import redis\n", + "\n", + "ssl_certfile=\"some-certificate.pem\"\n", + "ssl_keyfile=\"some-key.pem\"\n", + "ssl_ca_certs=ssl_certfile\n", + "\n", + "ssl_cert_conn = redis.Redis(\n", + " host=\"localhost\",\n", + " port=6666,\n", + " ssl=True,\n", + " ssl_certfile=ssl_certfile,\n", + " ssl_keyfile=ssl_keyfile,\n", + " ssl_cert_reqs=\"required\",\n", + " ssl_validate_ocsp=True\n", + ")\n", + "ssl_cert_conn.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connect via SSL, validate OCSP-stapled certificates\n", + "\n", + "The redis package is design to be small, meaning extra libraries must be installed, in order to support OCSP stapling. As a result, first install redis via:\n", + "\n", + "*pip install redis[ocsp]*\n", + "\n", + "This will install cryptography, requests, and PyOpenSSL, none of which are generally required to use Redis." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Using a custom SSL context and validating against an expected certificate" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import redis\n", + "import OpenSSL\n", + "\n", + "ssl_certfile=\"some-certificate.pem\"\n", + "ssl_keyfile=\"some-key.pem\"\n", + "ssl_ca_certs=ssl_certfile\n", + "ssl_expected_certificate = \"expected-ocsp-certificate.pem\"\n", + "\n", + "# PyOpenSSL is used only for the purpose of validating the ocsp\n", + "# stapled response\n", + "ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)\n", + "ctx.use_certificate_file=ssl_certfile\n", + "ctx.use_privatekey_file=ssl_keyfile\n", + "expected_certificate = open(ssl_expected_certificate, 'rb').read()\n", + "\n", + "ssl_cert_conn = redis.Redis(\n", + " host=\"localhost\",\n", + " port=6666,\n", + " ssl=True,\n", + " ssl_certfile=ssl_certfile,\n", + " ssl_keyfile=ssl_keyfile,\n", + " ssl_cert_reqs=\"required\",\n", + " ssl_ocsp_context=ctx,\n", + " ssl_ocsp_expected_cert=expected_certificate,\n", + ")\n", + "ssl_cert_conn.ping()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Naive validation of a stapled OCSP certificate" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import redis\n", + "import OpenSSL\n", + "\n", + "ssl_certfile=\"some-certificate.pem\"\n", + "ssl_keyfile=\"some-key.pem\"\n", + "ssl_ca_certs=ssl_certfile\n", + "ssl_expected_certificate = \"expected-ocsp-certificate.pem\"\n", + "\n", + "# PyOpenSSL is used only for the purpose of validating the ocsp\n", + "# stapled response\n", + "ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)\n", + "ctx.use_certificate_file=ssl_certfile\n", + "ctx.use_privatekey_file=ssl_keyfile\n", + "\n", + "ssl_cert_conn = redis.Redis(\n", + " host=\"localhost\",\n", + " port=6666,\n", + " ssl=True,\n", + " ssl_certfile=ssl_certfile,\n", + " ssl_keyfile=ssl_keyfile,\n", + " ssl_cert_reqs=\"required\",\n", + " ssl_validate_ocsp_stapled=True,\n", + ")\n", + "ssl_cert_conn.ping()" + ] + } + ], + "metadata": { + "interpreter": { + "hash": "d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe" + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/redis/client.py b/redis/client.py index 2832d2c103..612f91170a 100755 --- a/redis/client.py +++ b/redis/client.py @@ -880,6 +880,9 @@ def __init__( ssl_check_hostname=False, ssl_password=None, ssl_validate_ocsp=False, + ssl_validate_ocsp_stapled=False, + ssl_ocsp_context=None, + ssl_ocsp_expected_cert=None, max_connections=None, single_connection_client=False, health_check_interval=0, @@ -958,7 +961,10 @@ def __init__( "ssl_check_hostname": ssl_check_hostname, "ssl_password": ssl_password, "ssl_ca_path": ssl_ca_path, + "ssl_validate_ocsp_stapled": ssl_validate_ocsp_stapled, "ssl_validate_ocsp": ssl_validate_ocsp, + "ssl_ocsp_context": ssl_ocsp_context, + "ssl_ocsp_expected_cert": ssl_ocsp_expected_cert, } ) connection_pool = ConnectionPool(**kwargs) diff --git a/redis/connection.py b/redis/connection.py index 4178f67c57..5fdac54c00 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -908,6 +908,9 @@ def __init__( ssl_ca_path=None, ssl_password=None, ssl_validate_ocsp=False, + ssl_validate_ocsp_stapled=False, + ssl_ocsp_context=None, + ssl_ocsp_expected_cert=None, **kwargs, ): """Constructor @@ -921,6 +924,11 @@ def __init__( ssl_ca_path: The path to a directory containing several CA certificates in PEM format. Defaults to None. ssl_password: Password for unlocking an encrypted private key. Defaults to None. + ssl_validate_ocsp: If set, perform a full ocsp validation (i.e not a stapled verification) + ssl_validate_ocsp_stapled: If set, perform a validation on a stapled ocsp response + ssl_ocsp_context: A fully initialized OpenSSL.SSL.Context object to be used in verifying the ssl_ocsp_expected_cert + ssl_ocsp_expected_cert: A PEM armoured string containing the expected certificate to be returned from the ocsp verification service. + Raises: RedisError """ # noqa @@ -950,6 +958,9 @@ def __init__( self.check_hostname = ssl_check_hostname self.certificate_password = ssl_password self.ssl_validate_ocsp = ssl_validate_ocsp + self.ssl_validate_ocsp_stapled = ssl_validate_ocsp_stapled + self.ssl_ocsp_context = ssl_ocsp_context + self.ssl_ocsp_expected_cert = ssl_ocsp_expected_cert def _connect(self): "Wrap the socket with SSL support" @@ -968,7 +979,42 @@ def _connect(self): sslsock = context.wrap_socket(sock, server_hostname=self.host) if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False: raise RedisError("cryptography is not installed.") - elif self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE: + + if self.ssl_validate_ocsp_stapled and self.ssl_validate_ocsp: + raise RedisError( + "Either an OCSP staple or pure OCSP connection must be validated " + "- not both." + ) + + # validation for the stapled case + if self.ssl_validate_ocsp_stapled: + import OpenSSL + + from .ocsp import ocsp_staple_verifier + + # if a context is provided use it - otherwise, a basic context + if self.ssl_ocsp_context is None: + staple_ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) + staple_ctx.use_certificate_file(self.certfile) + staple_ctx.use_privatekey_file(self.keyfile) + else: + staple_ctx = self.ssl_ocsp_context + + staple_ctx.set_ocsp_client_callback( + ocsp_staple_verifier, + self.ssl_ocsp_expected_cert, + ) + + # need another socket + con = OpenSSL.SSL.Connection(staple_ctx, socket.socket()) + con.request_ocsp() + con.connect((self.host, self.port)) + con.do_handshake() + con.shutdown() + return sslsock + + # pure ocsp validation + if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE: from .ocsp import OCSPVerifier o = OCSPVerifier(sslsock, self.host, self.port, self.ca_certs) diff --git a/redis/ocsp.py b/redis/ocsp.py index 49aaddf48b..666c7dcd08 100644 --- a/redis/ocsp.py +++ b/redis/ocsp.py @@ -1,18 +1,170 @@ import base64 +import datetime import ssl from urllib.parse import urljoin, urlparse import cryptography.hazmat.primitives.hashes import requests from cryptography import hazmat, x509 +from cryptography.exceptions import InvalidSignature from cryptography.hazmat import backends +from cryptography.hazmat.primitives.asymmetric.dsa import DSAPublicKey +from cryptography.hazmat.primitives.asymmetric.ec import ECDSA, EllipticCurvePublicKey +from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from cryptography.hazmat.primitives.hashes import SHA1, Hash +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat from cryptography.x509 import ocsp from redis.exceptions import AuthorizationError, ConnectionError +def _verify_response(issuer_cert, ocsp_response): + pubkey = issuer_cert.public_key() + try: + if isinstance(pubkey, RSAPublicKey): + pubkey.verify( + ocsp_response.signature, + ocsp_response.tbs_response_bytes, + PKCS1v15(), + ocsp_response.signature_hash_algorithm, + ) + elif isinstance(pubkey, DSAPublicKey): + pubkey.verify( + ocsp_response.signature, + ocsp_response.tbs_response_bytes, + ocsp_response.signature_hash_algorithm, + ) + elif isinstance(pubkey, EllipticCurvePublicKey): + pubkey.verify( + ocsp_response.signature, + ocsp_response.tbs_response_bytes, + ECDSA(ocsp_response.signature_hash_algorithm), + ) + else: + pubkey.verify(ocsp_response.signature, ocsp_response.tbs_response_bytes) + except InvalidSignature: + raise ConnectionError("failed to valid ocsp response") + + +def _check_certificate(issuer_cert, ocsp_bytes, validate=True): + """A wrapper the return the validity of a known ocsp certificate""" + + ocsp_response = ocsp.load_der_ocsp_response(ocsp_bytes) + + if ocsp_response.response_status == ocsp.OCSPResponseStatus.UNAUTHORIZED: + raise AuthorizationError("you are not authorized to view this ocsp certificate") + if ocsp_response.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL: + if ocsp_response.certificate_status != ocsp.OCSPCertStatus.GOOD: + return False + else: + return False + + if ocsp_response.this_update >= datetime.datetime.now(): + raise ConnectionError("ocsp certificate was issued in the future") + + if ( + ocsp_response.next_update + and ocsp_response.next_update < datetime.datetime.now() + ): + raise ConnectionError("ocsp certificate has invalid update - in the past") + + responder_name = ocsp_response.responder_name + issuer_hash = ocsp_response.issuer_key_hash + responder_hash = ocsp_response.responder_key_hash + + cert_to_validate = issuer_cert + if ( + responder_name is not None + and responder_name == issuer_cert.subject + or responder_hash == issuer_hash + ): + cert_to_validate = issuer_cert + else: + certs = ocsp_response.certificates + responder_certs = _get_certificates( + certs, issuer_cert, responder_name, responder_hash + ) + + try: + responder_cert = responder_certs[0] + except IndexError: + raise ConnectionError("no certificates found for the responder") + + ext = responder_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage) + if ext is None or x509.oid.ExtendedKeyUsageOID.OCSP_SIGNING not in ext.value: + raise ConnectionError("delegate not autorized for ocsp signing") + cert_to_validate = responder_cert + + if validate: + _verify_response(cert_to_validate, ocsp_response) + return True + + +def _get_certificates(certs, issuer_cert, responder_name, responder_hash): + if responder_name is None: + certificates = [ + c + for c in certs + if _get_pubkey_hash(c) == responder_hash and c.issuer == issuer_cert.subject + ] + else: + certificates = [ + c + for c in certs + if c.subject == responder_name and c.issuer == issuer_cert.subject + ] + + return certificates + + +def _get_pubkey_hash(certificate): + pubkey = certificate.public_key() + + # https://stackoverflow.com/a/46309453/600498 + if isinstance(pubkey, RSAPublicKey): + h = pubkey.public_bytes(Encoding.DER, PublicFormat.PKCS1) + elif isinstance(pubkey, EllipticCurvePublicKey): + h = pubkey.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) + else: + h = pubkey.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + + sha1 = Hash(SHA1(), backend=backends.default_backend()) + sha1.update(h) + return sha1.finalize() + + +def ocsp_staple_verifier(con, ocsp_bytes, expected=None): + """An implemention of a function for set_ocsp_client_callback in PyOpenSSL. + + This function validates that the provide ocsp_bytes response is valid, + and matches the expected, stapled responses. + """ + if ocsp_bytes in [b"", None]: + raise ConnectionError("no ocsp response present") + + issuer_cert = None + peer_cert = con.get_peer_certificate().to_cryptography() + for c in con.get_peer_cert_chain(): + cert = c.to_cryptography() + if cert.subject == peer_cert.issuer: + issuer_cert = cert + break + + if issuer_cert is None: + raise ConnectionError("no matching issuer cert found in certificate chain") + + if expected is not None: + e = x509.load_pem_x509_certificate(expected) + if peer_cert != e: + raise ConnectionError("received and expected certificates do not match") + + return _check_certificate(issuer_cert, ocsp_bytes) + + class OCSPVerifier: - """A class to verify ssl sockets for RFC6960/RFC6961. + """A class to verify ssl sockets for RFC6960/RFC6961. This can be used + when using direct validation of OCSP responses and certificate revocations. @see https://datatracker.ietf.org/doc/html/rfc6960 @see https://datatracker.ietf.org/doc/html/rfc6961 @@ -67,7 +219,7 @@ def _certificate_components(self, cert): try: issuer = issuers[0].access_location.value except IndexError: - raise ConnectionError("no issuers in certificate") + issuer = None # now, the series of ocsp server entries ocsps = [ @@ -128,19 +280,7 @@ def check_certificate(self, server, cert, issuer_url): r = requests.get(ocsp_url, headers=header) if not r.ok: raise ConnectionError("failed to fetch ocsp certificate") - - ocsp_response = ocsp.load_der_ocsp_response(r.content) - if ocsp_response.response_status == ocsp.OCSPResponseStatus.UNAUTHORIZED: - raise AuthorizationError( - "you are not authorized to view this ocsp certificate" - ) - if ocsp_response.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL: - if ocsp_response.certificate_status == ocsp.OCSPCertStatus.REVOKED: - return False - else: - return True - else: - return False + return _check_certificate(issuer_cert, r.content, True) def is_valid(self): """Returns the validity of the certificate wrapping our socket. @@ -153,7 +293,11 @@ def is_valid(self): # validate the certificate try: cert, issuer_url, ocsp_server = self.components_from_socket() + if issuer_url is None: + raise ConnectionError("no issuers found in certificate chain") return self.check_certificate(ocsp_server, cert, issuer_url) except AuthorizationError: cert, issuer_url, ocsp_server = self.components_from_direct_connection() + if issuer_url is None: + raise ConnectionError("no issuers found in certificate chain") return self.check_certificate(ocsp_server, cert, issuer_url) diff --git a/setup.py b/setup.py index 559e521982..218fc27b84 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,6 @@ ], extras_require={ "hiredis": ["hiredis>=1.0.0"], - "cryptography": ["cryptography>=36.0.1", "requests>=2.26.0"], + "ocsp": ["cryptography>=36.0.1", "pyopenssl==20.0.1", "requests>=2.26.0"], }, ) diff --git a/tasks.py b/tasks.py index 313986d427..96005ca4e3 100644 --- a/tasks.py +++ b/tasks.py @@ -66,7 +66,7 @@ def standalone_tests(c): with and without hiredis.""" print("Starting Redis tests") _generate_keys() - run("tox -e standalone-'{plain,hiredis,cryptography}'") + run("tox -e standalone-'{plain,hiredis,ocsp}'") @task diff --git a/tests/test_ssl.py b/tests/test_ssl.py index a2f66b26ef..0ae7440daf 100644 --- a/tests/test_ssl.py +++ b/tests/test_ssl.py @@ -28,6 +28,9 @@ class TestSSL: if not os.path.isdir(CERT_DIR): raise IOError(f"No SSL certificates found. They should be in {CERT_DIR}") + SERVER_CERT = os.path.join(CERT_DIR, "server-cert.pem") + SERVER_KEY = os.path.join(CERT_DIR, "server-key.pem") + def test_ssl_with_invalid_cert(self, request): ssl_url = request.config.option.redis_ssl_url sslclient = redis.from_url(ssl_url) @@ -57,10 +60,10 @@ def test_validating_self_signed_certificate(self, request): host=p[0], port=p[1], ssl=True, - ssl_certfile=os.path.join(self.CERT_DIR, "server-cert.pem"), - ssl_keyfile=os.path.join(self.CERT_DIR, "server-key.pem"), + ssl_certfile=self.SERVER_CERT, + ssl_keyfile=self.SERVER_KEY, ssl_cert_reqs="required", - ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"), + ssl_ca_certs=self.SERVER_CERT, ) assert r.ping() @@ -71,10 +74,10 @@ def _create_oscp_conn(self, request): host=p[0], port=p[1], ssl=True, - ssl_certfile=os.path.join(self.CERT_DIR, "server-cert.pem"), - ssl_keyfile=os.path.join(self.CERT_DIR, "server-key.pem"), + ssl_certfile=self.SERVER_CERT, + ssl_keyfile=self.SERVER_KEY, ssl_cert_reqs="required", - ssl_ca_certs=os.path.join(self.CERT_DIR, "server-cert.pem"), + ssl_ca_certs=self.SERVER_CERT, ssl_validate_ocsp=True, ) return r @@ -159,3 +162,60 @@ def test_unauthorized_then_direct(self): with context.wrap_socket(sock, server_hostname=hostname) as wrapped: ocsp = OCSPVerifier(wrapped, hostname, 443) assert ocsp.is_valid() + + @skip_if_nocryptography() + def test_mock_ocsp_staple(self, request): + import OpenSSL + + ssl_url = request.config.option.redis_ssl_url + p = urlparse(ssl_url)[1].split(":") + r = redis.Redis( + host=p[0], + port=p[1], + ssl=True, + ssl_certfile=self.SERVER_CERT, + ssl_keyfile=self.SERVER_KEY, + ssl_cert_reqs="required", + ssl_ca_certs=self.SERVER_CERT, + ssl_validate_ocsp=True, + ssl_ocsp_context=p, # just needs to not be none + ) + + with pytest.raises(RedisError): + r.ping() + + ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD) + ctx.use_certificate_file(self.SERVER_CERT) + ctx.use_privatekey_file(self.SERVER_KEY) + + r = redis.Redis( + host=p[0], + port=p[1], + ssl=True, + ssl_certfile=self.SERVER_CERT, + ssl_keyfile=self.SERVER_KEY, + ssl_cert_reqs="required", + ssl_ca_certs=self.SERVER_CERT, + ssl_ocsp_context=ctx, + ssl_ocsp_expected_cert=open(self.SERVER_KEY, "rb").read(), + ssl_validate_ocsp_stapled=True, + ) + + with pytest.raises(ConnectionError) as e: + r.ping() + assert "no ocsp response present" in str(e) + + r = redis.Redis( + host=p[0], + port=p[1], + ssl=True, + ssl_certfile=self.SERVER_CERT, + ssl_keyfile=self.SERVER_KEY, + ssl_cert_reqs="required", + ssl_ca_certs=self.SERVER_CERT, + ssl_validate_ocsp_stapled=True, + ) + + with pytest.raises(ConnectionError) as e: + r.ping() + assert "no ocsp response present" in str(e) diff --git a/tox.ini b/tox.ini index ac7f01a6ca..abebf004ba 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ markers = [tox] minversion = 3.2.0 requires = tox-docker -envlist = {standalone,cluster}-{plain,hiredis,cryptography}-{py36,py37,py38,py39,pypy3},linters,docs +envlist = {standalone,cluster}-{plain,hiredis,ocsp}-{py36,py37,py38,py39,pypy3},linters,docs [docker:master] name = master @@ -128,7 +128,7 @@ docker = stunnel extras = hiredis: hiredis - cryptography: cryptography, requests + ocsp: cryptography, pyopenssl, requests setenv = CLUSTER_URL = "redis://localhost:16379/0" run_before = {toxinidir}/docker/stunnel/create_certs.sh From 0e30d8da4d1e7cba14bce4ab0e247a97d492d142 Mon Sep 17 00:00:00 2001 From: Jonathan Dieter Date: Mon, 17 Jan 2022 10:17:39 +0000 Subject: [PATCH 0342/1164] Add retries to connections in Sentinel Pools (#1879) --- redis/sentinel.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/redis/sentinel.py b/redis/sentinel.py index 025ab39c8c..b3f14907d0 100644 --- a/redis/sentinel.py +++ b/redis/sentinel.py @@ -37,7 +37,7 @@ def connect_to(self, address): if str_if_bytes(self.read_response()) != "PONG": raise ConnectionError("PING failed") - def connect(self): + def _connect_retry(self): if self._sock: return # already connected if self.connection_pool.is_master: @@ -50,6 +50,12 @@ def connect(self): continue raise SlaveNotFoundError # Never be here + def connect(self): + return self.retry.call_with_retry( + self._connect_retry, + lambda error: None, + ) + def read_response(self, disable_decoding=False): try: return super().read_response(disable_decoding=disable_decoding) From 90295ea422dffe54689458acc995d71aa16e0979 Mon Sep 17 00:00:00 2001 From: dvora-h <67596500+dvora-h@users.noreply.github.com> Date: Mon, 17 Jan 2022 12:38:02 +0200 Subject: [PATCH 0343/1164] 4.1.1 (#1883) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 218fc27b84..8b84c2a97a 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ long_description_content_type="text/markdown", keywords=["Redis", "key-value store", "database"], license="MIT", - version="4.1.0", + version="4.1.1", packages=find_packages( include=[ "redis", From c605690051baff404123e7fe328ac4e50eb00e72 Mon Sep 17 00:00:00 2001 From: Chayim Date: Tue, 18 Jan 2022 17:51:31 +0200 Subject: [PATCH 0344/1164] direct link to readthedocs (#1885) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f8e76701fe..166e80c23b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ The Python interface to the Redis key-value store. [![CI](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster) -[![docs](https://readthedocs.org/projects/redis-py/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) +[![docs](https://readthedocs.org/projects/redis/badge/?version=stable&style=flat)](https://redis-py.readthedocs.io/en/stable/) [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![pypi](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/) [![codecov](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg?token=yenl5fzxxr)](https://codecov.io/gh/redis/redis-py) @@ -41,6 +41,8 @@ or from source: $ python setup.py install ``` +View the current documentation [here](https://readthedocs.org/projects/redis/). + ## Contributing Want to contribute a feature, bug fix, or report an issue? Check out From afaa1c8f6d4bd7c66d6769a0a4b8d4f9a2d600d8 Mon Sep 17 00:00:00 2001 From: dvora-h <67596500+dvora-h@users.noreply.github.com> Date: Wed, 19 Jan 2022 14:51:01 +0200 Subject: [PATCH 0345/1164] Add search-json examples (#1886) --- docs/connections.rst | 2 + docs/examples.rst | 1 + .../connection_example-checkpoint.ipynb | 180 --------------- docs/examples/search_json_examples.ipynb | 214 ++++++++++++++++++ docs/redismodules.rst | 1 + 5 files changed, 218 insertions(+), 180 deletions(-) delete mode 100644 docs/examples/.ipynb_checkpoints/connection_example-checkpoint.ipynb create mode 100644 docs/examples/search_json_examples.ipynb diff --git a/docs/connections.rst b/docs/connections.rst index cf4657b95b..ba39f3341f 100644 --- a/docs/connections.rst +++ b/docs/connections.rst @@ -41,3 +41,5 @@ Connection Pools ***************** .. autoclass:: redis.connection.ConnectionPool :members: + +More connection examples can be found `here `_. \ No newline at end of file diff --git a/docs/examples.rst b/docs/examples.rst index 7a328afd82..cf70c09bf9 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -7,3 +7,4 @@ Examples examples/connection_examples examples/ssl_connection_examples + examples/search_json_examples diff --git a/docs/examples/.ipynb_checkpoints/connection_example-checkpoint.ipynb b/docs/examples/.ipynb_checkpoints/connection_example-checkpoint.ipynb deleted file mode 100644 index 04de8fe1c5..0000000000 --- a/docs/examples/.ipynb_checkpoints/connection_example-checkpoint.ipynb +++ /dev/null @@ -1,180 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Connect to redis running locally with default parameters " - ] - }, - { - "cell_type": "code", - "execution_count": 40, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], - "source": [ - "import redis\n", - "r = redis.Redis()\n", - "print(r.ping())" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Overwrite default parameters - connect to redis on specific host and port using username and password" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], - "source": [ - "import redis\n", - "r = redis.Redis(host='localhost', port=6380, username='dvora', password='redis')\n", - "print(r.ping())" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Create a SSL wrapped TCP socket connection" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], - "source": [ - "import redis\n", - "r = redis.Redis(host='localhost', port=6666, ssl=True, ssl_cert_reqs=\"none\")\n", - "print(r.ping())" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Add more parameters..." - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], - "source": [ - "import os\n", - "import redis\n", - "\n", - "ROOT = os.path.join(os.getcwd(), \"..\")\n", - "CERT_DIR = os.path.abspath(os.path.join(ROOT, \"docker\", \"stunnel\", \"keys\"))\n", - "\n", - "r = redis.Redis(\n", - " host=\"localhost\",\n", - " port=6666,\n", - " ssl=True,\n", - " ssl_certfile=os.path.join(CERT_DIR, \"server-cert.pem\"),\n", - " ssl_keyfile=os.path.join(CERT_DIR, \"server-key.pem\"),\n", - " ssl_cert_reqs=\"required\",\n", - " ssl_ca_certs=os.path.join(CERT_DIR, \"server-cert.pem\"),\n", - ")\n", - "print(r.ping())" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Connect to redis client object configured from given URL\n", - "##### Three URL schemes are supported:\n", - "\n", - "##### - `redis://` creates a TCP socket connection. See more at:\n", - "##### \n", - "##### - `rediss://` creates a SSL wrapped TCP socket connection. See more at:\n", - "##### \n", - "##### - ``unix://``: creates a Unix Domain Socket connection.\n", - "\n", - "##### Parameters are passed through querystring" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], - "source": [ - "import redis\n", - "r = redis.from_url(\"rediss://localhost:6666?ssl_cert_reqs=none\")\n", - "print(r.ping())" - ] - } - ], - "metadata": { - "interpreter": { - "hash": "d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe" - }, - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.9" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/examples/search_json_examples.ipynb b/docs/examples/search_json_examples.ipynb new file mode 100644 index 0000000000..6673663687 --- /dev/null +++ b/docs/examples/search_json_examples.ipynb @@ -0,0 +1,214 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Indexing / querying JSON documents\n", + "## Adding a JSON document to an index" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "b'OK'" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import redis\n", + "from redis.commands.json.path import Path\n", + "import redis.commands.search.aggregation as aggregations\n", + "import redis.commands.search.reducers as reducers\n", + "from redis.commands.search.field import TextField, NumericField, TagField\n", + "from redis.commands.search.indexDefinition import IndexDefinition, IndexType\n", + "from redis.commands.search.query import NumericFilter, Query\n", + "\n", + "\n", + "r = redis.Redis(host='localhost', port=36379)\n", + "user1 = {\n", + " \"user\":{\n", + " \"name\": \"Paul John\",\n", + " \"email\": \"paul.john@example.com\",\n", + " \"age\": 42,\n", + " \"city\": \"London\"\n", + " }\n", + "}\n", + "user2 = {\n", + " \"user\":{\n", + " \"name\": \"Eden Zamir\",\n", + " \"email\": \"eden.zamir@example.com\",\n", + " \"age\": 29,\n", + " \"city\": \"Tel Aviv\"\n", + " }\n", + "}\n", + "user3 = {\n", + " \"user\":{\n", + " \"name\": \"Paul Zamir\",\n", + " \"email\": \"paul.zamir@example.com\",\n", + " \"age\": 35,\n", + " \"city\": \"Tel Aviv\"\n", + " }\n", + "}\n", + "r.json().set(\"user:1\", Path.rootPath(), user1)\n", + "r.json().set(\"user:2\", Path.rootPath(), user2)\n", + "r.json().set(\"user:3\", Path.rootPath(), user3)\n", + "\n", + "schema = (TextField(\"$.user.name\", as_name=\"name\"),TagField(\"$.user.city\", as_name=\"city\"), NumericField(\"$.user.age\", as_name=\"age\"))\n", + "r.ft().create_index(schema, definition=IndexDefinition(prefix=[\"user:\"], index_type=IndexType.JSON))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Searching" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Simple search" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Result{2 total, docs: [Document {'id': 'user:1', 'payload': None, 'json': '{\"user\":{\"name\":\"Paul John\",\"email\":\"paul.john@example.com\",\"age\":42,\"city\":\"London\"}}'}, Document {'id': 'user:3', 'payload': None, 'json': '{\"user\":{\"name\":\"Paul Zamir\",\"email\":\"paul.zamir@example.com\",\"age\":35,\"city\":\"Tel Aviv\"}}'}]}" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.ft().search(\"Paul\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Filtering search results" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Result{1 total, docs: [Document {'id': 'user:3', 'payload': None, 'json': '{\"user\":{\"name\":\"Paul Zamir\",\"email\":\"paul.zamir@example.com\",\"age\":35,\"city\":\"Tel Aviv\"}}'}]}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "q1 = Query(\"Paul\").add_filter(NumericFilter(\"age\", 30, 40))\n", + "r.ft().search(q1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Projecting using JSON Path expressions " + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[Document {'id': 'user:1', 'payload': None, 'city': 'London'},\n", + " Document {'id': 'user:3', 'payload': None, 'city': 'Tel Aviv'}]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "r.ft().search(Query(\"Paul\").return_field(\"$.user.city\", as_field=\"city\")).docs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Aggregation" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[[b'age', b'35'], [b'age', b'42']]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "req = aggregations.AggregateRequest(\"Paul\").sort_by(\"@age\")\n", + "r.ft().aggregate(req).rows" + ] + } + ], + "metadata": { + "interpreter": { + "hash": "d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe" + }, + "kernelspec": { + "display_name": "Python 3.8.12 64-bit ('venv': venv)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/redismodules.rst b/docs/redismodules.rst index 24be4da5b4..07e756d87e 100644 --- a/docs/redismodules.rst +++ b/docs/redismodules.rst @@ -93,6 +93,7 @@ These are the commands for interacting with the `RedisJSON module `_. .. automodule:: redis.commands.json.commands :members: JSONCommands From 7d23974a62982d1b4b5586a648f8e7b17eccc6e5 Mon Sep 17 00:00:00 2001 From: Chayim Date: Wed, 19 Jan 2022 17:55:19 +0200 Subject: [PATCH 0346/1164] Documentation fixes: JSON Example, SSL Connection Examples, RTD version (#1887) --- docs/conf.py | 14 ++++++++++---- ...xamples.ipynb => ssl_connection_examples.ipynb} | 0 docs/redismodules.rst | 2 ++ 3 files changed, 12 insertions(+), 4 deletions(-) rename docs/examples/{ssl_connecton_examples.ipynb => ssl_connection_examples.ipynb} (100%) diff --git a/docs/conf.py b/docs/conf.py index 0f11442232..b99e46c879 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,12 @@ "sphinx.ext.autosectionlabel", ] +# AutosectionLabel settings. +# Uses a :

    6zW88RC*}~JGvkvv%P82b25}GL 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

  2. QxJQ6n{m$v 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�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