├── .codecov.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── async_redis ├── __init__.py ├── commands.py ├── connection.py ├── main.py ├── pipeline.py ├── pipeline_commands.py ├── py.typed ├── streams.py ├── typing.py ├── utils.py └── version.py ├── benchmarks ├── profile.py ├── requirements.txt ├── run.py ├── test_aioredis.py └── test_async_redis.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── requirements.txt ├── test_connection.py ├── test_main.py └── test_pipeline.py └── tools └── generate_stubs.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | range: [95, 100] 4 | status: 5 | patch: false 6 | project: false 7 | 8 | comment: 9 | layout: 'header, diff, flags, files, footer' 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | test: 13 | name: test py${{ matrix.python-version }} on ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu] 18 | python-version: ['3.7', '3.8'] 19 | 20 | env: 21 | PYTHON: ${{ matrix.python-version }} 22 | OS: ${{ matrix.os }} 23 | COMPILED: false 24 | 25 | runs-on: ${{ format('{0}-latest', matrix.os) }} 26 | 27 | services: 28 | redis: 29 | image: redis:5 30 | ports: 31 | - 6379:6379 32 | options: --entrypoint redis-server 33 | 34 | steps: 35 | - uses: actions/checkout@v2 36 | 37 | - name: set up python 38 | uses: actions/setup-python@v1 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | 42 | - name: install dependencies 43 | run: | 44 | make install 45 | pip freeze 46 | 47 | - name: generate stubs 48 | run: make generate-stubs 49 | 50 | - name: lint 51 | run: make lint 52 | 53 | - name: mypy 54 | run: make mypy 55 | 56 | - name: test 57 | run: | 58 | make test 59 | coverage xml 60 | 61 | - uses: samuelcolvin/codecov-action@env-vars 62 | with: 63 | file: ./coverage.xml 64 | env_vars: PYTHON,OS,COMPILED 65 | 66 | - name: compile 67 | run: | 68 | make compile-trace 69 | python -c "import sys, async_redis.version; print('compiled:', async_redis.version.COMPILED); sys.exit(0 if async_redis.version.COMPILED else 1)" 70 | ls -alh 71 | ls -alh async_redis/ 72 | 73 | - name: test compiled 74 | run: | 75 | make test 76 | coverage xml 77 | 78 | - uses: samuelcolvin/codecov-action@env-vars 79 | with: 80 | file: ./coverage.xml 81 | env_vars: PYTHON,OS,COMPILED 82 | env: 83 | COMPILED: true 84 | 85 | benchmark: 86 | name: run benchmarks 87 | runs-on: ubuntu-latest 88 | env: 89 | BENCHMARK_REPEATS: 2 90 | 91 | services: 92 | redis: 93 | image: redis:5 94 | ports: 95 | - 6379:6379 96 | options: --entrypoint redis-server 97 | 98 | steps: 99 | - uses: actions/checkout@v2 100 | 101 | - name: set up python 102 | uses: actions/setup-python@v1 103 | with: 104 | python-version: '3.8' 105 | 106 | - name: install and compile 107 | run: | 108 | make install 109 | make compile 110 | pip install -r benchmarks/requirements.txt 111 | 112 | - run: make benchmark 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /env*/ 2 | /.idea 3 | __pycache__/ 4 | *.py[cod] 5 | *.cache 6 | .pytest_cache/ 7 | .coverage.* 8 | /.coverage 9 | /htmlcov/ 10 | /build/ 11 | /dist/ 12 | /sandbox/ 13 | /demo.py 14 | *.egg-info 15 | /docs/_build/ 16 | /.mypy_cache/ 17 | .vscode/ 18 | .venv/ 19 | /.auto-format 20 | /async_redis/pipeline_commands.pyi 21 | /async_redis/*.c 22 | /async_redis/*.so 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Samuel Colvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | isort = isort -rc async_redis tests 3 | black = black -S -l 120 --target-version py37 async_redis tests 4 | 5 | .PHONY: install 6 | install: 7 | pip install -U pip setuptools 8 | pip install -r requirements.txt 9 | SKIP_CYTHON=1 pip install -e . 10 | 11 | .PHONY: generate-stubs 12 | generate-stubs: 13 | ./tools/generate_stubs.py 14 | 15 | .PHONY: compile-trace 16 | compile-trace: 17 | python setup.py build_ext --force --inplace --define CYTHON_TRACE 18 | 19 | .PHONY: compile 20 | compile: 21 | python setup.py build_ext --inplace 22 | 23 | .PHONY: format 24 | format: 25 | $(isort) 26 | $(black) 27 | 28 | .PHONY: lint 29 | lint: 30 | flake8 async_redis tests 31 | $(isort) --check-only -df 32 | $(black) --check 33 | 34 | .PHONY: test 35 | test: 36 | pytest --cov=async_redis 37 | 38 | .PHONY: testcov 39 | testcov: test 40 | @echo "building coverage html" 41 | @coverage html 42 | 43 | .PHONY: mypy 44 | mypy: 45 | mypy async_redis 46 | 47 | .PHONY: all 48 | all: generate-stubs lint mypy testcov 49 | 50 | .PHONY: benchmark 51 | benchmark: 52 | python benchmarks/run.py 53 | 54 | .PHONY: clean 55 | clean: 56 | rm -rf `find . -name __pycache__` 57 | rm -f `find . -type f -name '*.py[co]' ` 58 | rm -f `find . -type f -name '*~' ` 59 | rm -f `find . -type f -name '.*~' ` 60 | rm -rf .cache 61 | rm -rf .pytest_cache 62 | rm -rf .mypy_cache 63 | rm -rf htmlcov 64 | rm -rf *.egg-info 65 | rm -rf build 66 | rm -rf dist 67 | rm -f async_redis/*.c async_redis/*.so 68 | rm -f .coverage 69 | rm -f .coverage.* 70 | python setup.py clean 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # async-redis 2 | 3 | [![CI](https://github.com/samuelcolvin/async-redis/workflows/CI/badge.svg?event=push)](https://github.com/samuelcolvin/async-redis/actions?query=event%3Apush+branch%3Amaster+workflow%3ACI) 4 | [![Coverage](https://codecov.io/gh/samuelcolvin/async-redis/branch/master/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/async-redis) 5 | [![pypi](https://img.shields.io/pypi/v/async-redis.svg)](https://pypi.python.org/pypi/async-redis) 6 | [![versions](https://img.shields.io/pypi/pyversions/async-redis.svg)](https://github.com/samuelcolvin/async-redis) 7 | [![license](https://img.shields.io/github/license/samuelcolvin/async-redis.svg)](https://github.com/samuelcolvin/async-redis/blob/master/LICENSE) 8 | 9 | Python redis client using asyncio. 10 | 11 | **Currently a work in progress.** 12 | -------------------------------------------------------------------------------- /async_redis/__init__.py: -------------------------------------------------------------------------------- 1 | from .connection import ConnectionSettings # noqa F401 2 | from .main import Redis, connect # noqa F401 3 | from .version import VERSION # noqa F401 4 | -------------------------------------------------------------------------------- /async_redis/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from datetime import datetime 5 | from typing import Any, Coroutine, Dict, List, Optional, Tuple, TypeVar, Union 6 | 7 | from .typing import ArgType, CommandArgs, Literal, ReturnAs 8 | from .utils import apply_callback 9 | 10 | __all__ = ('AbstractCommands',) 11 | 12 | 13 | T = TypeVar('T', bytes, str, int, float, 'None') 14 | Result = Coroutine[Any, Any, T] 15 | 16 | 17 | class AbstractCommands: 18 | @abstractmethod 19 | def _execute(self, args: CommandArgs, return_as: ReturnAs) -> Any: 20 | ... 21 | 22 | """ 23 | String commands, see http://redis.io/commands/#string 24 | """ 25 | 26 | def append(self, key: ArgType, value: ArgType) -> Result[int]: 27 | """ 28 | Append a value to key. 29 | """ 30 | return self._execute((b'APPEND', key, value), 'int') 31 | 32 | def bitcount(self, key: ArgType, start: int = None, end: int = None) -> Result[int]: 33 | """ 34 | Count set bits in a string. 35 | """ 36 | command = [b'BITCOUNT', key] 37 | if start is not None and end is not None: 38 | command.extend([start, end]) 39 | elif not (start is None and end is None): 40 | raise TypeError('both start and stop must be specified, or neither') 41 | return self._execute(command, 'int') 42 | 43 | # TODO bitfield 44 | 45 | def bitop( 46 | self, dest: ArgType, op: Literal['AND', 'OR', 'XOR', 'NOT'], key: ArgType, *keys: ArgType 47 | ) -> Result[None]: 48 | """ 49 | Perform bitwise AND, OR, XOR or NOT operations between strings. 50 | """ 51 | return self._execute((b'BITOP', op, dest, key, *keys), 'ok') 52 | 53 | def bitpos(self, key: ArgType, bit: Literal[0, 1], start: int = None, end: int = None) -> Result[int]: 54 | """ 55 | Find first bit set or clear in a string. 56 | """ 57 | command: List[ArgType] = [b'BITPOS', key, bit] 58 | if start is not None: 59 | command.append(start) 60 | if end is not None: 61 | command.append(end) 62 | elif end is not None: 63 | command.extend([0, end]) 64 | return self._execute(command, 'int') 65 | 66 | def decr(self, key: ArgType) -> Result[int]: 67 | """ 68 | Decrement the integer value of a key by one. 69 | """ 70 | return self._execute((b'DECR', key), 'int') 71 | 72 | def decrby(self, key: ArgType, decrement: int) -> Result[int]: 73 | """ 74 | Decrement the integer value of a key by the given number. 75 | """ 76 | return self._execute((b'DECRBY', key, decrement), 'int') 77 | 78 | def get(self, key: ArgType, *, decode: bool = True) -> Result[str]: 79 | """ 80 | Get the value of a key. 81 | """ 82 | return self._execute((b'GET', key), 'str' if decode else None) 83 | 84 | def getbit(self, key: ArgType, offset: int) -> Result[int]: 85 | """ 86 | Returns the bit value at offset in the string value stored at key, offset must be an int greater than 0 87 | """ 88 | return self._execute((b'GETBIT', key, offset), 'int') 89 | 90 | def getrange(self, key: ArgType, start: int, end: int, *, decode: bool = True) -> Result[str]: 91 | """ 92 | Get a substring of the string stored at a key. 93 | """ 94 | return self._execute((b'GETRANGE', key, start, end), 'str' if decode else None) 95 | 96 | def getset(self, key: ArgType, value: ArgType, *, decode: bool = True) -> Result[str]: 97 | """ 98 | Set the string value of a key and return its old value. 99 | """ 100 | return self._execute((b'GETSET', key, value), 'str' if decode else None) 101 | 102 | def incr(self, key: ArgType) -> Result[int]: 103 | """ 104 | Increment the integer value of a key by one. 105 | """ 106 | return self._execute((b'INCR', key), 'int') 107 | 108 | def incrby(self, key: ArgType, increment: int) -> Result[int]: 109 | """ 110 | Increment the integer value of a key by the given amount. 111 | """ 112 | return self._execute((b'INCRBY', key, increment), 'int') 113 | 114 | def incrbyfloat(self, key: ArgType, increment: float) -> Result[float]: 115 | """ 116 | Increment the float value of a key by the given amount. 117 | """ 118 | return self._execute((b'INCRBYFLOAT', key, increment), 'float') 119 | 120 | def mget(self, key: ArgType, *keys: ArgType, decode: bool = True) -> Result[str]: 121 | """ 122 | Get the values of all the given keys. 123 | """ 124 | return self._execute((b'MGET', key, *keys), 'str' if decode else None) 125 | 126 | def mset(self, *args: Tuple[ArgType, ArgType], **kwargs: ArgType) -> Result[None]: 127 | """ 128 | Set multiple keys to multiple values or unpack dict to keys & values. 129 | """ 130 | command: List[ArgType] = [b'MSET'] 131 | for k1, v1 in args: 132 | command.extend([k1, v1]) 133 | for k2, v2 in kwargs.items(): 134 | command.extend([k2, v2]) 135 | 136 | return self._execute(command, 'ok') 137 | 138 | def msetnx(self, *args: Tuple[ArgType, ArgType], **kwargs: ArgType) -> Result[int]: 139 | """ 140 | Set multiple keys to multiple values, only if none of the keys exist. 141 | """ 142 | command: List[ArgType] = [b'MSETNX'] 143 | for k1, v1 in args: 144 | command.extend([k1, v1]) 145 | for k2, v2 in kwargs.items(): 146 | command.extend([k2, v2]) 147 | 148 | return self._execute(command, 'int') 149 | 150 | def psetex(self, key: ArgType, milliseconds: int, value: ArgType) -> Result[None]: 151 | """ 152 | Set the value and expiration in milliseconds of a key. 153 | """ 154 | return self._execute((b'PSETEX', key, milliseconds, value), 'ok') 155 | 156 | def set( 157 | self, 158 | key: ArgType, 159 | value: ArgType, 160 | *, 161 | expire: int = None, 162 | pexpire: int = None, 163 | if_exists: bool = None, 164 | if_not_exists: bool = None, 165 | ) -> Result[None]: 166 | """ 167 | Set the string value of a key. 168 | """ 169 | args = [b'SET', key, value] 170 | if expire: 171 | args.extend([b'EX', expire]) 172 | if pexpire: 173 | args.extend([b'PX', pexpire]) 174 | 175 | if if_exists: 176 | args.append(b'XX') 177 | elif if_not_exists: 178 | args.append(b'NX') 179 | return self._execute(args, 'ok') 180 | 181 | def setbit(self, key: ArgType, offset: int, value: Literal[0, 1]) -> Result[Literal[0, 1]]: 182 | """ 183 | Sets or clears the bit at offset in the string value stored at key. 184 | """ 185 | return self._execute((b'SETBIT', key, offset, value), 'int') 186 | 187 | def setex(self, key: ArgType, seconds: Union[int, float], value: ArgType) -> Result[None]: 188 | """ 189 | Set the value and expiration of a key. 190 | 191 | If seconds is float it will be multiplied by 1000 coerced to int and passed to `psetex` method. 192 | """ 193 | if isinstance(seconds, float): 194 | return self.psetex(key, int(seconds * 1000), value) 195 | else: 196 | return self._execute((b'SETEX', key, seconds, value), 'ok') 197 | 198 | def setnx(self, key: ArgType, value: ArgType) -> Result[bool]: 199 | """ 200 | Set the value of a key, only if the key does not exist. 201 | """ 202 | return self._execute((b'SETNX', key, value), 'bool') 203 | 204 | def setrange(self, key: ArgType, offset: int, value: ArgType) -> Result[int]: 205 | """ 206 | Overwrite part of a string at key starting at the specified offset. 207 | """ 208 | return self._execute((b'SETRANGE', key, offset, value), 'int') 209 | 210 | # TODO stralgo 211 | 212 | def strlen(self, key: ArgType) -> Result[int]: 213 | """ 214 | Get the length of the value stored in a key. 215 | """ 216 | return self._execute((b'STRLEN', key), 'int') 217 | 218 | """ 219 | For commands, see http://redis.io/commands/#server 220 | """ 221 | 222 | def bgrewriteaof(self) -> Result[None]: 223 | """ 224 | Asynchronously rewrite the append-only file. 225 | """ 226 | return self._execute((b'BGREWRITEAOF',), 'ok') 227 | 228 | def bgsave(self) -> Result[None]: 229 | """ 230 | Asynchronously save the dataset to disk. 231 | """ 232 | return self._execute((b'BGSAVE',), 'ok') 233 | 234 | def client_kill(self, *args: ArgType) -> Result[None]: 235 | """ 236 | Kill the connection of a client. 237 | """ 238 | return self._execute((b'CLIENT KILL', *args), 'ok') 239 | 240 | def client_list(self) -> Result[str]: 241 | """ 242 | Get the list of client connections. 243 | 244 | Returns list of ClientInfo named tuples. 245 | """ 246 | return self._execute((b'CLIENT', b'LIST'), 'str') 247 | 248 | def client_getname(self) -> Result[str]: 249 | """ 250 | Get the current connection name. 251 | """ 252 | return self._execute((b'CLIENT', b'GETNAME'), 'str') 253 | 254 | def client_pause(self, timeout: int) -> Result[int]: 255 | """ 256 | Stop processing commands from clients for *timeout* milliseconds. 257 | """ 258 | return self._execute((b'CLIENT', b'PAUSE', timeout), 'ok') 259 | 260 | def client_reply(self, set: Literal['ON', 'OFF', 'SKIP']) -> Result[None]: 261 | """ 262 | Instruct the server whether to reply to commands 263 | """ 264 | return self._execute((b'CLIENT', b'REPLY', set), 'ok') 265 | 266 | def client_setname(self, name: ArgType) -> Result[None]: 267 | """ 268 | Set the current connection name. 269 | """ 270 | return self._execute((b'CLIENT', b'SETNAME', name), 'ok') 271 | 272 | def command(self) -> Result[List[List[Union[int, str]]]]: 273 | """ 274 | Get array of Redis commands. 275 | """ 276 | return self._execute([b'COMMAND'], 'str') 277 | 278 | def command_count(self) -> Result[int]: 279 | """ 280 | Get total number of Redis commands. 281 | """ 282 | return self._execute((b'COMMAND', b'COUNT'), 'int') 283 | 284 | def command_getkeys(self, command: ArgType, *args: ArgType) -> Result[List[str]]: 285 | """ 286 | Extract keys given a full Redis command. 287 | """ 288 | return self._execute((b'COMMAND', b'GETKEYS', command, *args), 'str') 289 | 290 | def command_info(self, command: ArgType, *commands: ArgType) -> Result[List[List[Union[int, str]]]]: 291 | """ 292 | Get array of specific Redis command details. 293 | """ 294 | return self._execute((b'COMMAND', b'INFO', command, *commands), 'str') 295 | 296 | def config_get(self, parameter: Union[str, bytes] = '*') -> Result[Dict[str, str]]: 297 | """ 298 | Get the value of a configuration parameter(s). 299 | 300 | If called without argument will return all parameters. 301 | """ 302 | return apply_callback(self._execute((b'CONFIG', b'GET', parameter), 'str'), self._config_as_dict) 303 | 304 | @staticmethod 305 | def _config_as_dict(v: List[str]) -> Dict[str, str]: 306 | it = iter(v) 307 | return dict(zip(it, it)) 308 | 309 | def config_rewrite(self) -> Result[None]: 310 | """ 311 | Rewrite the configuration file with the in memory configuration. 312 | """ 313 | return self._execute((b'CONFIG', b'REWRITE'), 'ok') 314 | 315 | def config_set(self, parameter: Union[str, bytes], value: ArgType) -> Result[None]: 316 | """ 317 | Set a configuration parameter to the given value. 318 | """ 319 | return self._execute((b'CONFIG', b'SET', parameter, value), 'ok') 320 | 321 | def config_resetstat(self) -> Result[None]: 322 | """ 323 | Reset the stats returned by INFO. 324 | """ 325 | return self._execute((b'CONFIG', b'RESETSTAT',), 'ok') 326 | 327 | def dbsize(self) -> Result[int]: 328 | """ 329 | Return the number of keys in the selected database. 330 | """ 331 | return self._execute((b'DBSIZE',), 'int') 332 | 333 | def debug_sleep(self, timeout: int) -> Result[None]: 334 | """ 335 | Suspend connection for timeout seconds. 336 | """ 337 | return self._execute((b'DEBUG', b'SLEEP', timeout), 'ok') 338 | 339 | def debug_object(self, key: ArgType) -> Result[str]: 340 | """ 341 | Get debugging information about a key. 342 | """ 343 | return self._execute((b'DEBUG', b'OBJECT', key), 'str') 344 | 345 | def debug_segfault(self) -> Result[bytes]: 346 | """ 347 | Make the server crash. 348 | """ 349 | return self._execute((b'DEBUG', b'SEGFAULT'), None) 350 | 351 | def flushall(self, async_: bool = False) -> Result[None]: 352 | """ 353 | Remove all keys from all databases. 354 | 355 | :param async_: lets the entire dataset be freed asynchronously. Defaults False 356 | """ 357 | if async_: 358 | return self._execute((b'FLUSHALL', b'ASYNC'), 'ok') 359 | else: 360 | return self._execute((b'FLUSHALL',), 'ok') 361 | 362 | def flushdb(self, async_: bool = False) -> Result[None]: 363 | """ 364 | Remove all keys from the current database. 365 | 366 | :param async_: lets a single database be freed asynchronously. Defaults False 367 | """ 368 | if async_: 369 | return self._execute((b'FLUSHDB', b'ASYNC'), 'ok') 370 | else: 371 | return self._execute((b'FLUSHDB',), 'ok') 372 | 373 | def info( 374 | self, 375 | section: Literal[ 376 | 'all', 377 | 'default', 378 | 'server', 379 | 'clients', 380 | 'memory', 381 | 'persistence', 382 | 'stats', 383 | 'replication', 384 | 'cpu', 385 | 'commandstats', 386 | 'cluster', 387 | 'keyspace', 388 | ] = 'default', 389 | ) -> Result[Dict[str, Dict[str, str]]]: 390 | """ 391 | Get information and statistics about the server. 392 | 393 | If called without argument will return default set of sections. 394 | """ 395 | return apply_callback(self._execute((b'INFO', section), 'str'), self._parse_info) 396 | 397 | @staticmethod 398 | def _parse_info(info: str) -> Dict[str, Any]: 399 | res: Dict[str, Any] = {} 400 | for block in info.split('\r\n\r\n'): 401 | section, *extra = block.strip().splitlines() 402 | section = section[2:].lower() 403 | res[section] = tmp = {} 404 | for line in extra: 405 | value: Union[str, Dict[str, str]] 406 | key, value = line.split(':', 1) 407 | if ',' in line and '=' in line: 408 | value = dict(i.split('=', 1) for i in value.split(',')) # type: ignore 409 | tmp[key] = value 410 | return res 411 | 412 | def lastsave(self) -> Result[None]: 413 | """ 414 | Get the UNIX time stamp of the last successful save to disk. 415 | """ 416 | return self._execute((b'LASTSAVE',), None) 417 | 418 | # TODO monitor 419 | 420 | def role(self) -> Result[bytes]: 421 | """ 422 | Return the role of the server instance. 423 | 424 | Returns named tuples describing role of the instance. 425 | For fields information see http://redis.io/commands/role#output-format 426 | """ 427 | return self._execute((b'ROLE',), 'str') 428 | 429 | def save(self) -> Result[None]: 430 | """ 431 | Synchronously save the dataset to disk. 432 | """ 433 | return self._execute((b'SAVE',), 'ok') 434 | 435 | def shutdown(self, save: Optional[Literal['save', 'nosave']] = None) -> Result[None]: 436 | """ 437 | Synchronously save the dataset to disk and then shut down the server. 438 | """ 439 | if save == 'save': 440 | return self._execute((b'SHUTDOWN', b'SAVE'), 'ok') 441 | elif save == 'nosave': 442 | return self._execute((b'SHUTDOWN', b'NOSAVE'), 'ok') 443 | else: 444 | return self._execute((b'SHUTDOWN',), 'ok') 445 | 446 | def slaveof(self, host: Optional[str], port: Optional[str] = None) -> Result[None]: 447 | """ 448 | Make the server a slave of another instance, or promote it as master. 449 | 450 | Calling `slaveof(None)` will send `SLAVEOF NO ONE`. 451 | """ 452 | if host is None: 453 | return self._execute((b'SLAVEOF', b'NO', b'ONE'), 'ok') 454 | else: 455 | command: List[ArgType] = [b'SLAVEOF', host] 456 | if port: 457 | command.append(port) 458 | return self._execute(command, 'ok') 459 | 460 | def slowlog_get(self, length: Optional[int] = None) -> Result[bytes]: 461 | """ 462 | Returns the Redis slow queries log. 463 | """ 464 | command: List[ArgType] = [b'SLOWLOG', b'GET'] 465 | if length is not None: 466 | command.append(length) 467 | return self._execute(command, None) 468 | 469 | def slowlog_len(self) -> Result[int]: 470 | """ 471 | Returns length of Redis slow queries log. 472 | """ 473 | return self._execute((b'SLOWLOG', b'LEN'), 'int') 474 | 475 | def slowlog_reset(self) -> Result[None]: 476 | """ 477 | Resets Redis slow queries log. 478 | """ 479 | return self._execute((b'SLOWLOG', b'RESET',), 'ok') 480 | 481 | def sync(self) -> Result[bytes]: 482 | """ 483 | Redis-server internal command used for replication. 484 | """ 485 | return self._execute((b'SYNC',), None) 486 | 487 | def time(self) -> Result[datetime]: 488 | """ 489 | Return current server time. 490 | """ 491 | return apply_callback(self._execute((b'TIME',), 'int'), self._to_time) 492 | 493 | @staticmethod 494 | def _to_time(obj: Tuple[int, int]) -> datetime: 495 | s, ms = obj 496 | return datetime.fromtimestamp(s + ms / 1_000_000) 497 | -------------------------------------------------------------------------------- /async_redis/connection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Lock, StreamWriter 4 | from dataclasses import dataclass 5 | from typing import Any, Callable, Dict, List, Optional, Sequence 6 | 7 | from hiredis import hiredis 8 | 9 | from .streams import RedisStreamReader, open_connection 10 | from .typing import CommandArgs, ResultType, ReturnAs 11 | 12 | __all__ = 'ConnectionSettings', 'create_raw_connection', 'RawConnection' 13 | 14 | 15 | @dataclass 16 | class ConnectionSettings: 17 | """ 18 | Connection settings 19 | """ 20 | 21 | host: str = 'localhost' 22 | port: int = 6379 23 | database: int = 0 24 | password: Optional[str] = None 25 | encoding: str = 'utf8' 26 | 27 | def __repr__(self) -> str: 28 | # have to do it this way since asdict and __dict__ on dataclasses don't work with cython 29 | fields = 'host', 'port', 'database', 'password', 'encoding' 30 | return 'RedisSettings({})'.format(', '.join(f'{f}={getattr(self, f)!r}' for f in fields)) 31 | 32 | 33 | async def create_raw_connection(conn_settings: ConnectionSettings) -> 'RawConnection': 34 | """ 35 | Connect to a redis database and create a new RawConnection. 36 | """ 37 | reader, writer = await open_connection(conn_settings.host, conn_settings.port) 38 | return RawConnection(reader, writer, conn_settings.encoding) 39 | 40 | 41 | default_ok_msg: bytes = b'OK' 42 | return_as_lookup: Dict[str, Callable[[bytes], Any]] = { 43 | 'int': int, 44 | 'float': float, 45 | 'bool': bool, 46 | } 47 | 48 | 49 | class RawConnection: 50 | """ 51 | Low level interface to write to and read from redis. 52 | 53 | You probably don't want to use this directly 54 | """ 55 | 56 | __slots__ = '_reader', '_writer', '_encoding', '_hi_raw', '_hi_enc', '_lock', '_expected_ok_msg' 57 | 58 | def __init__(self, reader: RedisStreamReader, writer: StreamWriter, encoding: str): 59 | self._reader = reader 60 | self._writer = writer 61 | self._encoding = encoding 62 | self._lock = Lock() 63 | self._hi_raw = reader.hi_reader 64 | self._hi_enc = hiredis.Reader(encoding=encoding) 65 | self._expected_ok_msg: bytes = default_ok_msg 66 | 67 | async def execute(self, args: CommandArgs, return_as: ReturnAs = None) -> ResultType: 68 | buf = bytearray() 69 | self._encode_command(buf, args) 70 | self._set_reader_encoding(return_as) 71 | async with self._lock: 72 | self._writer.write(buf) 73 | del buf 74 | await self._writer.drain() 75 | return await self._read_result(return_as) 76 | 77 | async def execute_many(self, commands: Sequence[CommandArgs], return_as: ReturnAs = None) -> List[ResultType]: 78 | # TODO need tuples of command and return_as 79 | buf = bytearray() 80 | for args in commands: 81 | self._encode_command(buf, args) 82 | self._set_reader_encoding(None) 83 | async with self._lock: 84 | self._writer.write(buf) 85 | del buf 86 | await self._writer.drain() 87 | # TODO need to raise an error but read all answers first 88 | return [await self._read_result(return_as) for _ in range(len(commands))] 89 | 90 | async def close(self) -> None: 91 | async with self._lock: 92 | self._writer.close() 93 | await self._writer.wait_closed() 94 | 95 | def set_ok_msg(self, msg: bytes = default_ok_msg) -> None: 96 | self._expected_ok_msg = msg 97 | 98 | def _set_reader_encoding(self, return_as: ReturnAs) -> None: 99 | self._reader.hi_reader = self._hi_enc if return_as == 'str' else self._hi_raw 100 | 101 | async def _read_result(self, return_as: ReturnAs) -> ResultType: 102 | result = await self._reader.read_redis() 103 | 104 | if return_as in (None, 'str'): 105 | return result 106 | elif return_as == 'ok': 107 | if result != self._expected_ok_msg: 108 | # TODO this needs to be deferred for execute_many 109 | raise RuntimeError(f'unexpected result {result!r}') 110 | return None 111 | 112 | func = return_as_lookup[return_as] # type: ignore 113 | if isinstance(result, bytes): 114 | return func(result) 115 | else: 116 | # result must be a list 117 | return [func(r) for r in result] 118 | 119 | def _to_str(self, b: bytes) -> str: 120 | # TODO might be possible to change this once https://github.com/redis/hiredis-py/pull/96 gets released 121 | return b.decode(self._encoding) 122 | 123 | def _encode_command(self, buf: bytearray, args: CommandArgs) -> None: 124 | """ 125 | Encodes arguments into redis bulk-strings array. 126 | 127 | Raises TypeError if any arg is not a bytes, bytearray, str, int, or float. 128 | """ 129 | buf.extend(b'*%d\r\n' % len(args)) 130 | 131 | for arg in args: 132 | if isinstance(arg, bytes): 133 | bin_arg = arg 134 | elif isinstance(arg, str): 135 | bin_arg = arg.encode(self._encoding) 136 | elif isinstance(arg, int): 137 | bin_arg = b'%d' % arg 138 | elif isinstance(arg, float): 139 | bin_arg = f'{arg}'.encode('ascii') 140 | elif isinstance(arg, bytearray): 141 | bin_arg = bytes(arg) 142 | else: 143 | raise TypeError( 144 | f"Invalid argument: '{arg!r}' {arg.__class__} expected bytes, bytearray, str, int, or float" 145 | ) 146 | buf.extend(b'$%d\r\n%s\r\n' % (len(bin_arg), bin_arg)) 147 | -------------------------------------------------------------------------------- /async_redis/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from types import TracebackType 5 | from typing import Any, Generator, Optional, Type 6 | 7 | from .commands import AbstractCommands 8 | from .connection import ConnectionSettings, RawConnection, create_raw_connection 9 | from .pipeline import PipelineContext 10 | from .typing import CommandArgs, ResultType, ReturnAs 11 | 12 | __all__ = 'Redis', 'connect' 13 | 14 | 15 | class Redis(AbstractCommands): 16 | __slots__ = ('_conn',) 17 | 18 | def __init__(self, raw_connection: RawConnection): 19 | self._conn = raw_connection 20 | 21 | async def _execute(self, args: CommandArgs, return_as: ReturnAs) -> ResultType: 22 | # TODO probably need to shield self._conn.execute to avoid reading part of an answer 23 | return await self._conn.execute(args, return_as=return_as) 24 | 25 | def pipeline(self) -> PipelineContext: 26 | return PipelineContext(self._conn) 27 | 28 | async def close(self) -> None: 29 | await self._conn.close() 30 | 31 | 32 | def connect( 33 | connection_settings: Optional[ConnectionSettings] = None, 34 | *, 35 | host: str = None, 36 | port: int = None, 37 | database: int = None, 38 | ) -> 'RedisConnector': 39 | if connection_settings: 40 | conn_settings = connection_settings 41 | else: 42 | kwargs = dict(host=host, port=port, database=database) 43 | conn_settings = ConnectionSettings(**{k: v for k, v in kwargs.items() if v is not None}) # type: ignore 44 | 45 | return RedisConnector(conn_settings) 46 | 47 | 48 | class RedisConnector: 49 | """ 50 | Simple shim to allow both "await async_redis.connect(...)" and "async with async_redis.connect(...)" to work 51 | """ 52 | 53 | def __init__(self, conn_settings: ConnectionSettings): 54 | self.conn_settings = conn_settings 55 | self.redis: Optional[Redis] = None 56 | self.lock = asyncio.Lock() 57 | 58 | async def open(self) -> Redis: 59 | async with self.lock: 60 | if self.redis is None: 61 | conn = await create_raw_connection(self.conn_settings) 62 | self.redis = Redis(conn) 63 | return self.redis 64 | 65 | def __await__(self) -> Generator[Any, None, Redis]: 66 | return self.open().__await__() 67 | 68 | async def __aenter__(self) -> Redis: 69 | return await self.open() 70 | 71 | async def __aexit__( 72 | self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType] 73 | ) -> None: 74 | async with self.lock: 75 | if self.redis is not None: 76 | await self.redis.close() 77 | self.redis = None 78 | -------------------------------------------------------------------------------- /async_redis/pipeline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from types import TracebackType 4 | from typing import Optional, Type 5 | 6 | from .connection import RawConnection 7 | from .pipeline_commands import CommandsPipeline 8 | 9 | __all__ = ('PipelineContext',) 10 | 11 | 12 | class PipelineContext: 13 | def __init__(self, raw_connection: RawConnection) -> None: 14 | self._conn = raw_connection 15 | self._pipeline: Optional[CommandsPipeline] = None 16 | 17 | async def __aenter__(self) -> CommandsPipeline: 18 | self._pipeline = CommandsPipeline(self._conn) 19 | return self._pipeline 20 | 21 | async def __aexit__( 22 | self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType] 23 | ) -> None: 24 | if exc_type: 25 | self._pipeline = None 26 | elif self._pipeline is not None: 27 | await self._pipeline.execute() 28 | -------------------------------------------------------------------------------- /async_redis/pipeline_commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List 4 | 5 | from .commands import AbstractCommands 6 | from .connection import RawConnection 7 | from .typing import CommandArgs, ResultType, ReturnAs 8 | 9 | __all__ = ('CommandsPipeline',) 10 | 11 | 12 | class CommandsPipeline(AbstractCommands): 13 | def __init__(self, raw_connection: RawConnection) -> None: 14 | self._conn = raw_connection 15 | self._pipeline: List[CommandArgs] = [] 16 | 17 | def _execute(self, args: CommandArgs, return_as: ReturnAs) -> None: 18 | self._pipeline.append(args) 19 | 20 | async def execute(self, return_as: ReturnAs = None) -> List[ResultType]: 21 | r: List[ResultType] = [] 22 | if self._pipeline: 23 | r = await self._conn.execute_many(self._pipeline, return_as) 24 | self._pipeline = [] 25 | return r 26 | -------------------------------------------------------------------------------- /async_redis/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelcolvin/async-redis/1828a346453c9e95d9f7f3e771b92e97b1ebe5e0/async_redis/py.typed -------------------------------------------------------------------------------- /async_redis/streams.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union 5 | 6 | from hiredis import hiredis 7 | 8 | __all__ = ('open_connection', 'RedisStreamReader') 9 | 10 | _DEFAULT_LIMIT = 2 ** 16 # 64 KiB 11 | 12 | 13 | async def open_connection( 14 | host: str, port: int, *, limit: int = _DEFAULT_LIMIT, **kwds: Any 15 | ) -> Tuple['RedisStreamReader', asyncio.StreamWriter]: 16 | loop = asyncio.get_event_loop() 17 | reader = RedisStreamReader(limit=limit, loop=loop) 18 | protocol = asyncio.StreamReaderProtocol(reader, loop=loop) 19 | transport, _ = await loop.create_connection(lambda: protocol, host, port, **kwds) 20 | writer = asyncio.StreamWriter(transport, protocol, reader, loop) 21 | return reader, writer 22 | 23 | 24 | class RedisStreamReader(asyncio.StreamReader): 25 | """ 26 | Modified asyncio.StreamReader that uses a hiredis.Reader instead of bytearray as a buffer, otherwise 27 | this class attempts to keep the flow control logic unchanged 28 | """ 29 | 30 | __slots__ = '_limit', '_loop', '_eof', '_waiter', '_exception', '_transport', '_paused', 'hi_reader' 31 | _source_traceback = None 32 | 33 | def __init__(self, limit: int, loop: asyncio.AbstractEventLoop): 34 | if limit <= 0: 35 | raise ValueError('Limit cannot be <= 0') 36 | 37 | self._limit = limit 38 | self._loop = loop 39 | self._eof: bool = False # Whether we're done. 40 | self._waiter: Optional[asyncio.Future[None]] = None # A future used by _wait_for_data() 41 | self._exception: Optional[Exception] = None 42 | self._transport: Optional[asyncio.Transport] = None 43 | self._paused: bool = False 44 | self.hi_reader = hiredis.Reader() 45 | 46 | def feed_data(self, data: bytes) -> None: 47 | assert not self._eof, 'feed_data after feed_eof' 48 | 49 | if not data: 50 | return 51 | 52 | self.hi_reader.feed(data) 53 | self._wakeup_waiter() 54 | 55 | if self._transport is not None and not self._paused and self.hi_reader.len() > 2 * self._limit: 56 | try: 57 | self._transport.pause_reading() 58 | except NotImplementedError: 59 | # The transport can't be paused. 60 | # We'll just have to buffer all data. 61 | # Forget the transport so we don't keep trying. 62 | self._transport = None 63 | else: 64 | self._paused = True 65 | 66 | async def read_redis(self) -> Union[bytes, List[bytes]]: 67 | """ 68 | Return a parsed Redis object or an exception when something wrong happened. 69 | """ 70 | if self._exception is not None: 71 | raise self._exception 72 | 73 | while True: 74 | obj = self.hi_reader.gets() 75 | 76 | if obj is not False: 77 | self._maybe_resume_transport() 78 | return obj 79 | 80 | if self._eof: 81 | self.hi_reader = hiredis.Reader() 82 | raise asyncio.IncompleteReadError(b'', None) 83 | 84 | await self._wait_for_data('read_redis') 85 | 86 | def _maybe_resume_transport(self) -> None: 87 | if self._paused and self.hi_reader.len() <= self._limit: 88 | self._paused = False 89 | self._transport.resume_reading() # type: ignore 90 | 91 | # to satisfy mypy since the type hints for asyncio.StreamReader are wrong 92 | if TYPE_CHECKING: 93 | 94 | def _wakeup_waiter(self) -> None: 95 | ... 96 | 97 | async def _wait_for_data(self, func: str) -> None: 98 | ... 99 | -------------------------------------------------------------------------------- /async_redis/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import List, Optional, Sequence, Union 5 | 6 | __all__ = 'Literal', 'ArgType', 'CommandArgs', 'ReturnAs', 'ResultType' 7 | 8 | if sys.version_info >= (3, 8): 9 | from typing import Literal 10 | else: 11 | from typing_extensions import Literal 12 | 13 | ArgType = Union[bytes, bytearray, str, int, float] 14 | CommandArgs = Sequence[ArgType] 15 | ResultType = Union[None, bytes, str, int, float, List[bytes], List[str], List[int], List[float]] 16 | 17 | ReturnAs = Optional[Literal['ok', 'str', 'int', 'float', 'bool']] 18 | -------------------------------------------------------------------------------- /async_redis/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Future 4 | from typing import Any, Callable, List, TypeVar 5 | 6 | __all__ = ('apply_callback',) 7 | 8 | T = TypeVar('T', str, List[str]) 9 | 10 | 11 | async def apply_callback(fut: Future[T], converter: Callable[[T], Any]) -> Any: 12 | result = await fut 13 | if result == b'QUEUED': 14 | return None 15 | else: 16 | return converter(result) 17 | -------------------------------------------------------------------------------- /async_redis/version.py: -------------------------------------------------------------------------------- 1 | __all__ = 'VERSION', 'COMPILED' 2 | 3 | VERSION = '0.0.1' 4 | 5 | try: 6 | import cython # type: ignore 7 | except ImportError: 8 | COMPILED: bool = False 9 | else: # pragma: no cover 10 | try: 11 | COMPILED = cython.compiled 12 | except AttributeError: 13 | COMPILED = False 14 | -------------------------------------------------------------------------------- /benchmarks/profile.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from line_profiler import LineProfiler 4 | 5 | from async_redis.connection import RawConnection, create_raw_connection 6 | from async_redis.main import Redis 7 | from test_async_redis import TestAsyncRedis 8 | 9 | 10 | async def run(): 11 | await TestAsyncRedis.run(1000, 1000) 12 | 13 | 14 | def run_sync(): 15 | asyncio.run(run()) 16 | 17 | 18 | funcs_to_profile = [create_raw_connection] 19 | module_objects = {**vars(RawConnection), **vars(Redis)} 20 | funcs_to_profile += [v for v in module_objects.values() if str(v).startswith('{lpad}} ({i + 1:>{len(str(repeats))}}/{repeats}) time={time:0.3f}s') 30 | times.append(time) 31 | 32 | print(f'{p:>{lpad}} best={min(times):0.3f}s, avg={mean(times):0.3f}s, stdev={stdev(times):0.3f}s') 33 | avg = mean(times) / total_queries * 1e6 34 | sd = stdev(times) / total_queries * 1e6 35 | results.append(f'{p:>{lpad}} best={min(times) / total_queries * 1e6:0.3f}μs/query ' 36 | f'avg={avg:0.3f}μs/query stdev={sd:0.3f}μs/query version={test_class.version}') 37 | print() 38 | 39 | for r in results: 40 | print(r) 41 | 42 | 43 | if __name__ == '__main__': 44 | uvloop.install() 45 | asyncio.run(main()) 46 | -------------------------------------------------------------------------------- /benchmarks/test_aioredis.py: -------------------------------------------------------------------------------- 1 | import aioredis 2 | 3 | 4 | class TestAioredis: 5 | package = 'aioredis' 6 | version = aioredis.__version__ 7 | 8 | @staticmethod 9 | async def run(set_queries: int, get_queries: int): 10 | redis = await aioredis.create_redis_pool('redis://localhost') 11 | await redis.set('my-key', 'value') 12 | for i in range(set_queries): 13 | await redis.set(f'foo_{i}', i) 14 | 15 | for i in range(get_queries): 16 | r = await redis.get(f'foo_{i}', encoding='utf-8') 17 | assert r == str(i), r 18 | 19 | redis.close() 20 | await redis.wait_closed() 21 | -------------------------------------------------------------------------------- /benchmarks/test_async_redis.py: -------------------------------------------------------------------------------- 1 | import async_redis 2 | 3 | 4 | class TestAsyncRedis: 5 | package = 'async-redis' 6 | version = async_redis.VERSION 7 | 8 | @staticmethod 9 | async def run(set_queries: int, get_queries: int): 10 | async with async_redis.connect() as redis: 11 | for i in range(set_queries): 12 | await redis.set(f'foo_{i}', i) 13 | 14 | for i in range(get_queries): 15 | r = await redis.get(f'foo_{i}') 16 | assert r == str(i), r 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Cython==3.0a3;sys_platform!='win32' 2 | uvloop==0.14.0 3 | -r benchmarks/requirements.txt 4 | -r tests/requirements.txt 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | timeout = 10 4 | filterwarnings = 5 | error 6 | ignore::DeprecationWarning:distutils 7 | 8 | [flake8] 9 | max-line-length = 120 10 | max-complexity = 14 11 | inline-quotes = single 12 | multiline-quotes = double 13 | ignore = E203, W503 14 | 15 | [coverage:run] 16 | source = async_redis 17 | branch = True 18 | 19 | [coverage:report] 20 | precision = 2 21 | exclude_lines = 22 | pragma: no cover 23 | raise NotImplementedError 24 | raise NotImplemented 25 | if TYPE_CHECKING: 26 | @overload 27 | 28 | [isort] 29 | line_length=120 30 | known_first_party=async_redis 31 | known_standard_library=dataclasses 32 | multi_line_output=3 33 | include_trailing_comma=True 34 | force_grid_wrap=0 35 | combine_as_imports=True 36 | 37 | [mypy] 38 | follow_imports = silent 39 | strict_optional = True 40 | warn_redundant_casts = True 41 | warn_unused_ignores = True 42 | disallow_any_generics = True 43 | check_untyped_defs = True 44 | no_implicit_reexport = True 45 | warn_unused_configs = True 46 | disallow_subclassing_any = True 47 | disallow_incomplete_defs = True 48 | disallow_untyped_decorators = True 49 | disallow_untyped_calls = True 50 | 51 | # for strict mypy: (this is the tricky one :-)) 52 | disallow_untyped_defs = True 53 | 54 | # remaining arguments from `mypy --strict` which cause errors 55 | ;no_implicit_optional = True 56 | ;warn_return_any = True 57 | 58 | [mypy-hiredis] 59 | ignore_missing_imports = true 60 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from importlib.machinery import SourceFileLoader 4 | from pathlib import Path 5 | 6 | from setuptools import setup 7 | 8 | description = 'Python redis client using asyncio' 9 | THIS_DIR = Path(__file__).resolve().parent 10 | try: 11 | long_description = (THIS_DIR / 'README.md').read_text() 12 | except FileNotFoundError: 13 | long_description = description 14 | 15 | # avoid loading the package before requirements are installed: 16 | version = SourceFileLoader('version', 'async_redis/version.py').load_module() 17 | 18 | ext_modules = None 19 | if not any(arg in sys.argv for arg in ['clean', 'check']) and 'SKIP_CYTHON' not in os.environ: 20 | try: 21 | from Cython.Build import cythonize 22 | except ImportError: 23 | pass 24 | else: 25 | # For cython test coverage install with `make build-cython-trace` 26 | compiler_directives = {} 27 | if 'CYTHON_TRACE' in sys.argv: 28 | compiler_directives['linetrace'] = True 29 | os.environ['CFLAGS'] = '-O3' 30 | ext_modules = cythonize( 31 | 'async_redis/*.py', 32 | nthreads=int(os.getenv('CYTHON_NTHREADS', 0)), 33 | language_level=3, 34 | compiler_directives=compiler_directives, 35 | ) 36 | 37 | setup( 38 | name='async-redis', 39 | version=version.VERSION, 40 | description=description, 41 | long_description=long_description, 42 | long_description_content_type='text/markdown', 43 | classifiers=[ 44 | 'Development Status :: 4 - Bet', 45 | 'Programming Language :: Python', 46 | 'Programming Language :: Python :: 3', 47 | 'Programming Language :: Python :: 3 :: Only', 48 | 'Programming Language :: Python :: 3.7', 49 | 'Programming Language :: Python :: 3.8', 50 | 'Intended Audience :: Developers', 51 | 'Intended Audience :: Information Technology', 52 | 'Intended Audience :: System Administrators', 53 | 'License :: OSI Approved :: MIT License', 54 | 'Operating System :: Unix', 55 | 'Operating System :: POSIX :: Linux', 56 | 'Environment :: Console', 57 | 'Environment :: MacOS X', 58 | 'Topic :: Software Development :: Libraries :: Python Modules', 59 | 'Topic :: Internet', 60 | ], 61 | author='Samuel Colvin', 62 | author_email='s@muelcolvin.com', 63 | url='https://github.com/samuelcolvin/async-redis', 64 | license='MIT', 65 | packages=['async_redis'], 66 | package_data={'async_redis': ['py.typed']}, 67 | python_requires='>=3.7', 68 | zip_safe=False, 69 | install_requires=[ 70 | 'hiredis>=1.0.1', 71 | 'typing-extensions>=3.7;python_version<"3.8"' 72 | ], 73 | ext_modules=ext_modules, 74 | ) 75 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samuelcolvin/async-redis/1828a346453c9e95d9f7f3e771b92e97b1ebe5e0/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pytest import fixture 4 | 5 | from async_redis import ConnectionSettings, Redis, connect 6 | from async_redis.connection import RawConnection, create_raw_connection 7 | 8 | 9 | @fixture(name='settings') 10 | def fix_settings(loop): 11 | return ConnectionSettings() 12 | 13 | 14 | @fixture(name='raw_connection') 15 | def fix_raw_connection(loop, settings: ConnectionSettings): 16 | conn: RawConnection = loop.run_until_complete(create_raw_connection(settings)) 17 | loop.run_until_complete(conn.execute([b'FLUSHALL'])) 18 | yield conn 19 | loop.run_until_complete(conn.close()) 20 | 21 | 22 | @fixture(name='redis') 23 | def fix_redis(loop, settings: ConnectionSettings): 24 | asyncio.set_event_loop(loop) 25 | conn: Redis = loop.run_until_complete(connect(settings)) 26 | loop.run_until_complete(conn.flushall()) 27 | yield conn 28 | loop.run_until_complete(conn.close()) 29 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | black==19.10b0 2 | coverage==5.1 3 | flake8==3.7.9 4 | flake8-quotes==3 5 | isort==4.3.21 6 | msgpack==0.6.1 7 | mypy==0.770 8 | pycodestyle==2.5.0 9 | pyflakes==2.1.1 10 | pytest==5.4.1 11 | pytest-cov==2.8.1 12 | pytest-mock==3 13 | pytest-sugar==0.9.3 14 | pytest-timeout==1.3.3 15 | pytest-toolbox==0.4 16 | twine==3.1.1 17 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_redis.connection import ConnectionSettings, RawConnection, create_raw_connection 4 | 5 | 6 | async def test_connect(): 7 | s = ConnectionSettings() 8 | conn = await create_raw_connection(s) 9 | try: 10 | r = await conn.execute([b'ECHO', b'hello']) 11 | assert r == b'hello' 12 | finally: 13 | await conn.close() 14 | 15 | 16 | async def test_return_as_int(raw_connection: RawConnection): 17 | r = await raw_connection.execute([b'ECHO', 123], 'int') 18 | assert r == 123 19 | 20 | 21 | async def test_return_as_int_list(raw_connection: RawConnection): 22 | assert 1 == await raw_connection.execute(['RPUSH', 'mylist', 1]) 23 | assert 2 == await raw_connection.execute(['RPUSH', 'mylist', 2]) 24 | assert 3 == await raw_connection.execute(['RPUSH', 'mylist', 3]) 25 | r = await raw_connection.execute(['LRANGE', 'mylist', 0, -1], 'int') 26 | assert r == [1, 2, 3] 27 | 28 | 29 | async def test_settings_repr(): 30 | s = ConnectionSettings() 31 | assert repr(s) == "RedisSettings(host='localhost', port=6379, database=0, password=None, encoding='utf8')" 32 | assert str(s) == "RedisSettings(host='localhost', port=6379, database=0, password=None, encoding='utf8')" 33 | 34 | 35 | async def test_encode_invalid(raw_connection: RawConnection): 36 | with pytest.raises(TypeError, match=r"Invalid argument: '\[1\]' expected"): 37 | await raw_connection.execute([b'ECHO', [1]]) 38 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from async_redis import connect 2 | 3 | 4 | async def test_simple(): 5 | async with connect() as redis: 6 | assert None is await redis.set('foo', 123) 7 | assert '123' == await redis.get('foo') 8 | -------------------------------------------------------------------------------- /tests/test_pipeline.py: -------------------------------------------------------------------------------- 1 | from async_redis import Redis 2 | 3 | 4 | async def test_simple(redis: Redis): 5 | async with redis.pipeline() as p: 6 | p.set('foo', 1) 7 | p.set('bar', 2) 8 | p.get('foo') 9 | p.set('foo', 3) 10 | p.get('foo') 11 | # v = await p.execute() 12 | # debug(v) 13 | -------------------------------------------------------------------------------- /tools/generate_stubs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | from pathlib import Path 4 | 5 | ROOT_DIR = Path(__file__).parent.parent 6 | func_regex = re.compile(r'( {4}def [a-z][a-z_]+\(.*?\) -> )Result.*?\n( {8}""".+?"""\n {8})', flags=re.S) 7 | 8 | HEAD = """\ 9 | from typing import Any, Coroutine, List, Tuple, TypeVar, Union 10 | 11 | from .commands import AbstractCommands 12 | from .connection import RawConnection 13 | from .typing import ArgType, CommandArgs, Literal, ResultType, ReturnAs 14 | 15 | __all__ = ('CommandsPipeline',) 16 | 17 | 18 | class CommandsPipeline(AbstractCommands): 19 | _conn: RawConnection 20 | _pipeline: List[CommandArgs] 21 | 22 | def __init__(self, raw_connection: RawConnection): 23 | ... 24 | 25 | def _execute(self, args: CommandArgs, return_as: ReturnAs) -> None: 26 | ... 27 | 28 | async def execute(self, return_as: ReturnAs = None) -> List[ResultType]: 29 | ... 30 | 31 | """ 32 | 33 | 34 | def main(): 35 | commands_text = (ROOT_DIR / 'async_redis' / 'commands.py').read_text() 36 | 37 | matches = func_regex.findall(commands_text[commands_text.find('String commands'):]) 38 | 39 | funcs = [] 40 | for func_def, docstring in matches: 41 | if '\n' in func_def: 42 | func_def = func_def.replace('(\n', '( # type: ignore\n') 43 | f = f'{func_def}None:\n{docstring}pass\n' 44 | else: 45 | f = f'{func_def}None: # type: ignore\n{docstring}pass\n' 46 | funcs.append(f) 47 | 48 | stubs = HEAD + '\n'.join(funcs) 49 | path = ROOT_DIR / 'async_redis' / 'pipeline_commands.pyi' 50 | path.write_text(stubs) 51 | print(f'pipeline_commands.py stubs written to {path}') 52 | 53 | 54 | if __name__ == '__main__': 55 | main() 56 | --------------------------------------------------------------------------------