summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndy McCurdy <andy@andymccurdy.com>2019-09-29 22:30:42 -0700
committerAndy McCurdy <andy@andymccurdy.com>2019-12-28 22:51:12 -0800
commit8df8cd54d135380ad8b3b8807a67a3e6915b0b49 (patch)
tree4450b8b35b7bf17a30832e75a61fb42b6c2d68c6
parent1671ef2fcca68854020d54078bee58224c2f3893 (diff)
downloadredis-py-8df8cd54d135380ad8b3b8807a67a3e6915b0b49.tar.gz
Added support for ACL commands
-rw-r--r--.travis.yml2
-rw-r--r--CHANGES2
-rwxr-xr-xredis/client.py249
-rwxr-xr-xredis/connection.py41
-rw-r--r--redis/exceptions.py4
-rw-r--r--tests/test_commands.py176
-rw-r--r--tests/test_connection_pool.py82
7 files changed, 536 insertions, 20 deletions
diff --git a/.travis.yml b/.travis.yml
index c28cac4..64351b9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -24,7 +24,7 @@ matrix:
- python: 3.8
env: TOXENV=py38-hiredis
before_install:
- - wget http://download.redis.io/releases/redis-5.0.3.tar.gz && mkdir redis_install && tar -xvzf redis-5.0.3.tar.gz -C redis_install && cd redis_install/redis-5.0.3 && make && src/redis-server --daemonize yes && cd ../..
+ - 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 ../..
- redis-cli info
install:
- pip install codecov tox
diff --git a/CHANGES b/CHANGES
index 894b05d..68fb10c 100644
--- a/CHANGES
+++ b/CHANGES
@@ -4,6 +4,8 @@
without actually running any other commands. Thanks @brianmaissy.
#1233, #1234
* Removed support for end of life Python 3.4.
+ * Added support for all ACL commands in Redis 6. Thanks @IAmATeaPot418
+ for helping.
* 3.3.11
* Further fix for the SSLError -> TimeoutError mapping to work
on obscure releases of Python 2.7.
diff --git a/redis/client.py b/redis/client.py
index 35d2cfe..c0ac25e 100755
--- a/redis/client.py
+++ b/redis/client.py
@@ -470,6 +470,30 @@ def parse_client_kill(response, **options):
return nativestr(response) == 'OK'
+def parse_acl_getuser(response, **options):
+ if response is None:
+ return None
+ 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'])
+
+ # split 'commands' into separate 'categories' and 'commands' lists
+ commands, categories = [], []
+ 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']
+ return data
+
+
class Redis(object):
"""
Implementation of the Redis protocol.
@@ -526,6 +550,16 @@ class Redis(object):
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,
@@ -609,9 +643,9 @@ class Redis(object):
For example::
- redis://[:password]@localhost:6379/0
- rediss://[:password]@localhost:6379/0
- unix://[:password]@/path/to/socket.sock?db=0
+ 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:
@@ -640,7 +674,7 @@ class Redis(object):
return cls(connection_pool=connection_pool)
def __init__(self, host='localhost', port=6379,
- db=0, password=None, socket_timeout=None,
+ db=0, username=None, password=None, socket_timeout=None,
socket_connect_timeout=None,
socket_keepalive=None, socket_keepalive_options=None,
connection_pool=None, unix_socket_path=None,
@@ -663,6 +697,7 @@ class Redis(object):
kwargs = {
'db': db,
+ 'username': username,
'password': password,
'socket_timeout': socket_timeout,
'encoding': encoding,
@@ -867,6 +902,212 @@ class Redis(object):
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_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
+ ``add_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')
diff --git a/redis/connection.py b/redis/connection.py
index 81b437b..b90cafe 100755
--- a/redis/connection.py
+++ b/redis/connection.py
@@ -22,6 +22,7 @@ from redis.exceptions import (
DataError,
ExecAbortError,
InvalidResponse,
+ NoPermissionError,
NoScriptError,
ReadOnlyError,
RedisError,
@@ -139,6 +140,7 @@ class BaseParser(object):
'NOSCRIPT': NoScriptError,
'READONLY': ReadOnlyError,
'NOAUTH': AuthenticationError,
+ 'NOPERM': NoPermissionError,
}
def parse_error(self, response):
@@ -485,10 +487,11 @@ class Connection(object):
"Manages TCP communication to and from a Redis server"
description_format = "Connection<host=%(host)s,port=%(port)s,db=%(db)s>"
- 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',
+ def __init__(self, host='localhost', port=6379, db=0, username=None,
+ 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):
@@ -496,6 +499,7 @@ class Connection(object):
self.host = host
self.port = int(port)
self.db = db
+ self.username = username
self.password = password
self.socket_timeout = socket_timeout
self.socket_connect_timeout = socket_connect_timeout or socket_timeout
@@ -610,13 +614,17 @@ class Connection(object):
"Initialize the connection, authenticate and select a database"
self._parser.on_connect(self)
- # if a password is specified, authenticate
- if self.password:
+ # if username and/or password are set, authenticate
+ if self.username or self.password:
+ if self.username:
+ 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', self.password, check_health=False)
+ self.send_command('AUTH', *auth_args, check_health=False)
if nativestr(self.read_response()) != 'OK':
- raise AuthenticationError('Invalid Password')
+ raise AuthenticationError('Invalid Username or Password')
# if a database is specified, switch to it
if self.db:
@@ -832,7 +840,7 @@ class SSLConnection(Connection):
class UnixDomainSocketConnection(Connection):
description_format = "UnixDomainSocketConnection<path=%(path)s,db=%(db)s>"
- def __init__(self, path='', db=0, password=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,
@@ -841,6 +849,7 @@ class UnixDomainSocketConnection(Connection):
self.pid = os.getpid()
self.path = path
self.db = db
+ self.username = username
self.password = password
self.socket_timeout = socket_timeout
self.retry_on_timeout = retry_on_timeout
@@ -904,9 +913,9 @@ class ConnectionPool(object):
For example::
- redis://[:password]@localhost:6379/0
- rediss://[:password]@localhost:6379/0
- unix://[:password]@/path/to/socket.sock?db=0
+ 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:
@@ -931,7 +940,7 @@ class ConnectionPool(object):
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``, and ``password`` components.
+ ``path``, ``username`` and ``password`` components.
Any additional querystring arguments and keyword arguments will be
passed along to the ConnectionPool class's initializer. The querystring
@@ -960,17 +969,20 @@ class ConnectionPool(object):
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:
- password = url.password
+ 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,
@@ -980,6 +992,7 @@ class ConnectionPool(object):
url_options.update({
'host': hostname,
'port': int(url.port or 6379),
+ 'username': username,
'password': password,
})
diff --git a/redis/exceptions.py b/redis/exceptions.py
index e7f2cbb..9a1852a 100644
--- a/redis/exceptions.py
+++ b/redis/exceptions.py
@@ -53,6 +53,10 @@ class ReadOnlyError(ResponseError):
pass
+class NoPermissionError(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 ef316af..00752fa 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -7,7 +7,7 @@ import redis
import time
from redis._compat import (unichr, ascii_letters, iteritems, iterkeys,
- itervalues, long)
+ itervalues, long, basestring)
from redis.client import parse_info
from redis import exceptions
@@ -66,6 +66,180 @@ class TestRedisCommands(object):
r['a']
# SERVER INFORMATION
+ @skip_if_server_version_lt('5.9.101')
+ 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('5.9.101')
+ 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('5.9.101')
+ def test_acl_deluser(self, r, request):
+ username = 'redis-py-user'
+
+ def teardown():
+ r.acl_deluser(username)
+
+ request.addfinalizer(teardown)
+
+ assert r.acl_deluser(username) == 0
+ assert r.acl_setuser(username, enabled=False, reset=True)
+ assert r.acl_deluser(username) == 1
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_acl_genpass(self, r):
+ password = r.acl_genpass()
+ assert isinstance(password, basestring)
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_acl_getuser_setuser(self, r, request):
+ 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)
+ assert r.acl_getuser(username) == {
+ 'categories': ['-@all'],
+ 'commands': [],
+ 'enabled': False,
+ 'flags': ['off'],
+ 'keys': [],
+ 'passwords': [],
+ }
+
+ # 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': [],
+ }
+
+ # 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:*'])
+ acl = r.acl_getuser(username)
+ 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 set(acl['keys']) == set([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:*'])
+ acl = r.acl_getuser(username)
+ 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 set(acl['keys']) == set([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
+
+ # 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])
+ acl = r.acl_getuser(username)
+ 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
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_acl_list(self, r, request):
+ username = 'redis-py-user'
+
+ def teardown():
+ r.acl_deluser(username)
+ request.addfinalizer(teardown)
+
+ assert r.acl_setuser(username, enabled=False, reset=True)
+ users = r.acl_list()
+ assert 'user %s off -@all' % username in users
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_acl_setuser_categories_without_prefix_fails(self, r, request):
+ username = 'redis-py-user'
+
+ def teardown():
+ r.acl_deluser(username)
+ request.addfinalizer(teardown)
+
+ with pytest.raises(exceptions.DataError):
+ r.acl_setuser(username, categories=['list'])
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_acl_setuser_commands_without_prefix_fails(self, r, request):
+ username = 'redis-py-user'
+
+ def teardown():
+ r.acl_deluser(username)
+ request.addfinalizer(teardown)
+
+ with pytest.raises(exceptions.DataError):
+ r.acl_setuser(username, commands=['get'])
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request):
+ 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)
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_acl_users(self, r):
+ users = r.acl_users()
+ assert isinstance(users, list)
+ assert len(users) > 0
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_acl_whoami(self, r):
+ username = r.acl_whoami()
+ assert isinstance(username, basestring)
+
def test_client_list(self, r):
clients = r.client_list()
assert isinstance(clients[0], dict)
diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py
index 406b5db..e0f0822 100644
--- a/tests/test_connection_pool.py
+++ b/tests/test_connection_pool.py
@@ -199,6 +199,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'localhost',
'port': 6379,
'db': 0,
+ 'username': None,
'password': None,
}
@@ -209,6 +210,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'myhost',
'port': 6379,
'db': 0,
+ 'username': None,
'password': None,
}
@@ -220,6 +222,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'my / host +=+',
'port': 6379,
'db': 0,
+ 'username': None,
'password': None,
}
@@ -230,6 +233,33 @@ class TestConnectionPoolURLParsing(object):
'host': 'localhost',
'port': 6380,
'db': 0,
+ 'username': None,
+ 'password': None,
+ }
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_username(self):
+ pool = redis.ConnectionPool.from_url('redis://myuser:@localhost')
+ assert pool.connection_class == redis.Connection
+ assert pool.connection_kwargs == {
+ 'host': 'localhost',
+ 'port': 6379,
+ 'db': 0,
+ 'username': 'myuser',
+ 'password': None,
+ }
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_quoted_username(self):
+ pool = redis.ConnectionPool.from_url(
+ 'redis://%2Fmyuser%2F%2B name%3D%24+:@localhost',
+ decode_components=True)
+ assert pool.connection_class == redis.Connection
+ assert pool.connection_kwargs == {
+ 'host': 'localhost',
+ 'port': 6379,
+ 'db': 0,
+ 'username': '/myuser/+ name=$+',
'password': None,
}
@@ -240,6 +270,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'localhost',
'port': 6379,
'db': 0,
+ 'username': None,
'password': 'mypassword',
}
@@ -252,9 +283,22 @@ class TestConnectionPoolURLParsing(object):
'host': 'localhost',
'port': 6379,
'db': 0,
+ 'username': None,
'password': '/mypass/+ word=$+',
}
+ @skip_if_server_version_lt('5.9.101')
+ def test_username_and_password(self):
+ pool = redis.ConnectionPool.from_url('redis://myuser:mypass@localhost')
+ assert pool.connection_class == redis.Connection
+ assert pool.connection_kwargs == {
+ 'host': 'localhost',
+ 'port': 6379,
+ 'db': 0,
+ 'username': 'myuser',
+ 'password': 'mypass',
+ }
+
def test_db_as_argument(self):
pool = redis.ConnectionPool.from_url('redis://localhost', db='1')
assert pool.connection_class == redis.Connection
@@ -262,6 +306,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'localhost',
'port': 6379,
'db': 1,
+ 'username': None,
'password': None,
}
@@ -272,6 +317,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'localhost',
'port': 6379,
'db': 2,
+ 'username': None,
'password': None,
}
@@ -283,6 +329,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'localhost',
'port': 6379,
'db': 3,
+ 'username': None,
'password': None,
}
@@ -300,6 +347,7 @@ class TestConnectionPoolURLParsing(object):
'socket_timeout': 20.0,
'socket_connect_timeout': 10.0,
'retry_on_timeout': True,
+ 'username': None,
'password': None,
}
assert pool.max_connections == 10
@@ -339,6 +387,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'localhost',
'port': 6379,
'db': 0,
+ 'username': None,
'password': None,
'a': '1',
'b': '2'
@@ -355,6 +404,7 @@ class TestConnectionPoolURLParsing(object):
'host': 'myhost',
'port': 6379,
'db': 0,
+ 'username': None,
'password': None,
}
@@ -370,6 +420,31 @@ class TestConnectionPoolUnixSocketURLParsing(object):
assert pool.connection_kwargs == {
'path': '/socket',
'db': 0,
+ 'username': None,
+ 'password': None,
+ }
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_username(self):
+ pool = redis.ConnectionPool.from_url('unix://myuser:@/socket')
+ assert pool.connection_class == redis.UnixDomainSocketConnection
+ assert pool.connection_kwargs == {
+ 'path': '/socket',
+ 'db': 0,
+ 'username': 'myuser',
+ 'password': None,
+ }
+
+ @skip_if_server_version_lt('5.9.101')
+ def test_quoted_username(self):
+ pool = redis.ConnectionPool.from_url(
+ 'unix://%2Fmyuser%2F%2B name%3D%24+:@/socket',
+ decode_components=True)
+ assert pool.connection_class == redis.UnixDomainSocketConnection
+ assert pool.connection_kwargs == {
+ 'path': '/socket',
+ 'db': 0,
+ 'username': '/myuser/+ name=$+',
'password': None,
}
@@ -379,6 +454,7 @@ class TestConnectionPoolUnixSocketURLParsing(object):
assert pool.connection_kwargs == {
'path': '/socket',
'db': 0,
+ 'username': None,
'password': 'mypassword',
}
@@ -390,6 +466,7 @@ class TestConnectionPoolUnixSocketURLParsing(object):
assert pool.connection_kwargs == {
'path': '/socket',
'db': 0,
+ 'username': None,
'password': '/mypass/+ word=$+',
}
@@ -401,6 +478,7 @@ class TestConnectionPoolUnixSocketURLParsing(object):
assert pool.connection_kwargs == {
'path': '/my/path/to/../+_+=$ocket',
'db': 0,
+ 'username': None,
'password': 'mypassword',
}
@@ -410,6 +488,7 @@ class TestConnectionPoolUnixSocketURLParsing(object):
assert pool.connection_kwargs == {
'path': '/socket',
'db': 1,
+ 'username': None,
'password': None,
}
@@ -419,6 +498,7 @@ class TestConnectionPoolUnixSocketURLParsing(object):
assert pool.connection_kwargs == {
'path': '/socket',
'db': 2,
+ 'username': None,
'password': None,
}
@@ -428,6 +508,7 @@ class TestConnectionPoolUnixSocketURLParsing(object):
assert pool.connection_kwargs == {
'path': '/socket',
'db': 0,
+ 'username': None,
'password': None,
'a': '1',
'b': '2'
@@ -443,6 +524,7 @@ class TestSSLConnectionURLParsing(object):
'host': 'localhost',
'port': 6379,
'db': 0,
+ 'username': None,
'password': None,
}