diff -Nru fakeredis-2.4.0/debian/changelog fakeredis-2.10.3/debian/changelog --- fakeredis-2.4.0/debian/changelog 2022-12-27 14:44:56.000000000 +0000 +++ fakeredis-2.10.3/debian/changelog 2023-04-14 04:46:25.000000000 +0000 @@ -1,11 +1,11 @@ -fakeredis (2.4.0-1ppa1~jammy) jammy; urgency=low +fakeredis (2.10.3-2ppa1~jammy) jammy; urgency=low * Modifications for PPA release. - -- Joachim Metz Tue, 27 Dec 2022 15:44:56 +0100 + -- Joachim Metz Fri, 14 Apr 2023 06:46:25 +0200 -fakeredis (2.4.0-1) unstable; urgency=low +fakeredis (2.10.3-1) unstable; urgency=low * Auto-generated - -- log2timeline development team Tue, 27 Dec 2022 15:44:56 -0100 + -- log2timeline development team Fri, 14 Apr 2023 06:46:25 -0100 diff -Nru fakeredis-2.4.0/debian/control fakeredis-2.10.3/debian/control --- fakeredis-2.4.0/debian/control 2022-12-27 14:44:56.000000000 +0000 +++ fakeredis-2.10.3/debian/control 2023-04-14 04:46:25.000000000 +0000 @@ -2,7 +2,7 @@ Section: python Priority: extra Maintainer: Bruce Merry -Build-Depends: debhelper (>= 9), dh-python, python3-all (>= 3.6~), python3-setuptools +Build-Depends: debhelper (>= 9), dh-python, pybuild-plugin-pyproject, python3-all (>= 3.6~), python3-setuptools, python3-poetry-core Standards-Version: 4.1.4 X-Python3-Version: >= 3.6 Homepage: https://github.com/jamesls/fakeredis diff -Nru fakeredis-2.4.0/fakeredis/aioredis.py fakeredis-2.10.3/fakeredis/aioredis.py --- fakeredis-2.4.0/fakeredis/aioredis.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/aioredis.py 2023-04-03 23:14:58.068066100 +0000 @@ -4,15 +4,22 @@ import sys from typing import Union, Optional +import redis + if sys.version_info >= (3, 8): from typing import Type, TypedDict else: from typing_extensions import Type, TypedDict -import async_timeout +if sys.version_info >= (3, 11): + from asyncio import timeout as async_timeout +else: + from async_timeout import timeout as async_timeout + import redis.asyncio as redis_async # aioredis was integrated into redis in version 4.2.0 as redis.asyncio +from redis.asyncio.connection import BaseParser -from . import _fakesocket +from . import _fakesocket, FakeServer from . import _helpers from . import _msgs as msgs from . import _server @@ -29,7 +36,7 @@ async def _async_blocking(self, timeout, func, event, callback): result = None try: - async with async_timeout.timeout(timeout if timeout else None): + async with async_timeout(timeout if timeout else None): while True: await event.wait() event.clear() @@ -69,7 +76,8 @@ _connection_error_class = redis_async.ConnectionError def _decode_error(self, error): - return redis_async.connection.BaseParser(1).parse_error(error.value) + parser = BaseParser(1) if redis.VERSION < (5, 0) else BaseParser() + return parser.parse_error(error.value) class FakeReader: @@ -100,7 +108,9 @@ class FakeConnection(redis_async.Connection): def __init__(self, *args, **kwargs): - self._server = kwargs.pop('server') + self._server = kwargs.pop('server', None) + if self._server is None: + self._server = FakeServer() self._sock = None super().__init__(*args, **kwargs) diff -Nru fakeredis-2.4.0/fakeredis/_basefakesocket.py fakeredis-2.10.3/fakeredis/_basefakesocket.py --- fakeredis-2.4.0/fakeredis/_basefakesocket.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/_basefakesocket.py 2023-04-03 23:14:58.068066100 +0000 @@ -6,7 +6,13 @@ import redis +if redis.VERSION >= (5, 0): + from redis.parsers import BaseParser +else: + from redis.connection import BaseParser + from . import _msgs as msgs +from ._command_args_parsing import extract_args from ._commands import ( Int, Float, SUPPORTED_COMMANDS, COMMANDS_WITH_SUB, key_value_type) from ._helpers import ( @@ -151,7 +157,7 @@ return result def _decode_error(self, error): - return redis.connection.BaseParser().parse_error(error.value) + return BaseParser().parse_error(error.value) def _decode_result(self, result): """Convert SimpleString and SimpleError, recursively""" @@ -266,22 +272,8 @@ returned exactly once. """ cursor = int(cursor) - pattern = None - _type = None - count = 10 - if len(args) % 2 != 0: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) - for i in range(0, len(args), 2): - if casematch(args[i], b'match'): - pattern = args[i + 1] - elif casematch(args[i], b'count'): - count = Int.decode(args[i + 1]) - if count <= 0: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) - elif casematch(args[i], b'type'): - _type = args[i + 1] - else: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) + (pattern, _type, count), _ = extract_args(args, ('*match', '*type', '+count')) + count = 10 if count is None else count if cursor >= len(keys): return [0, []] diff -Nru fakeredis-2.4.0/fakeredis/_command_args_parsing.py fakeredis-2.10.3/fakeredis/_command_args_parsing.py --- fakeredis-2.4.0/fakeredis/_command_args_parsing.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/fakeredis/_command_args_parsing.py 2023-04-03 23:14:58.068066100 +0000 @@ -0,0 +1,143 @@ +from typing import Tuple, List, Dict, Any + +from . import _msgs as msgs +from ._commands import Int, Float +from ._helpers import SimpleError, null_terminate + + +def _count_params(s: str): + res = 0 + while s[res] in '.+*~': + res += 1 + return res + + +def _encode_arg(s: str): + return s[_count_params(s):].encode() + + +def _default_value(s: str): + if s[0] == '~': + return None + ind = _count_params(s) + if ind == 0: + return False + elif ind == 1: + return None + else: + return [None] * ind + + +def extract_args( + actual_args: Tuple[bytes, ...], + expected: Tuple[str, ...], + error_on_unexpected: bool = True, + left_from_first_unexpected: bool = True, +) -> Tuple[List, List]: + """Parse argument values + + Extract from actual arguments which arguments exist and their value if relevant. + + Parameters: + - actual_args: + The actual arguments to parse + - expected: + Arguments to look for, see below explanation. + - error_on_unexpected: + Should an error be raised when actual_args contain an unexpected argument? + - left_from_first_unexpected: + Once reaching an unexpected argument in actual_args, + Should parsing stop? + Returns: + - List of values for expected arguments. + - List of remaining args. + + An expected argument can have parameters: + - A numerical (Int) parameter is identified with +. + - A float (Float) parameter is identified with . + - A non-numerical parameter is identified with a *. + - A argument with potentially ~ or = between the + argument name and the value is identified with a ~. + - A numberical argument with potentially ~ or = between the + argument name and the value is identified with a ~+. + + e.g. + '++limit' will translate as an argument with 2 int parameters. + + >>> extract_args((b'nx', b'ex', b'324', b'xx',), ('nx', 'xx', '+ex', 'keepttl')) + [True, True, 324, False], None + + >>> extract_args( + (b'maxlen', b'10',b'nx', b'ex', b'324', b'xx',), + ('~+maxlen', 'nx', 'xx', '+ex', 'keepttl')) + 10, [True, True, 324, False], None + """ + args_info: Dict[bytes, int] = { + _encode_arg(k): (i, _count_params(k)) + for (i, k) in enumerate(expected) + } + + def _parse_params( + key: str, + ind: int, + actual_args: Tuple[bytes, ...]) -> Tuple[Any, int]: + """ + Parse an argument from actual args. + """ + pos, expected_following = args_info[key] + argument_name = expected[pos] + + # Deal with parameters with optional ~/= before numerical value. + if argument_name[0] == '~': + if ind + 1 >= len(actual_args): + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if actual_args[ind + 1] != b'~' and actual_args[ind + 1] != b'=': + arg, parsed = actual_args[ind + 1], 1 + elif ind + 2 >= len(actual_args): + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + else: + arg, parsed = actual_args[ind + 2], 2 + if argument_name[1] == '+': + arg = Int.decode(arg) + return arg, parsed + # Boolean parameters + if expected_following == 0: + return True, 0 + + if ind + expected_following >= len(actual_args): + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + temp_res = [] + for i in range(expected_following): + curr_arg = actual_args[ind + i + 1] + if argument_name[i] == '+': + curr_arg = Int.decode(curr_arg) + elif argument_name[i] == '.': + curr_arg = Float.decode(curr_arg) + temp_res.append(curr_arg) + + if len(temp_res) == 1: + return temp_res[0], expected_following + else: + return temp_res, expected_following + + results: List = [_default_value(key) for key in expected] + left_args = [] + i = 0 + while i < len(actual_args): + found = False + for key in args_info: + if null_terminate(actual_args[i]).lower() == key: + arg_position, _ = args_info[key] + results[arg_position], parsed = _parse_params(key, i, actual_args) + i += parsed + found = True + break + + if not found: + if error_on_unexpected: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if left_from_first_unexpected: + return results, actual_args[i:] + left_args.append(actual_args[i]) + i += 1 + return results, left_args diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/bitmap_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/bitmap_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/bitmap_mixin.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/bitmap_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -1,11 +1,46 @@ from fakeredis import _msgs as msgs -from fakeredis._commands import (command, Key, Int, BitOffset, BitValue, fix_range_string) +from fakeredis._commands import (command, Key, Int, BitOffset, BitValue, fix_range_string, fix_range) from fakeredis._helpers import SimpleError, casematch class BitmapCommandsMixin: # BITMAP commands # TODO: bitfield, bitfield_ro, bitpos + @staticmethod + def _bytes_as_bin_string(value): + return ''.join([bin(i).lstrip('0b').rjust(8, '0') for i in value]) + + @command((Key(bytes), Int), (bytes,)) + def bitpos(self, key, bit, *args): + if bit != 0 and bit != 1: + raise SimpleError(msgs.BIT_ARG_MUST_BE_ZERO_OR_ONE) + if len(args) > 3: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if len(args) == 3 and self.version < 7: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + bit_mode = False + if len(args) == 3 and self.version >= 7: + bit_mode = casematch(args[2], b'bit') + if not bit_mode and not casematch(args[2], b'byte'): + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + start = 0 if len(args) == 0 else Int.decode(args[0]) + bit_chr = str(bit) + key_value = key.value if key.value else b'' + + if bit_mode: + value = self._bytes_as_bin_string(key_value) + end = len(value) if len(args) <= 1 else Int.decode(args[1]) + start, end = fix_range(start, end, len(value)) + value = value[start:end] + else: + end = len(key_value) if len(args) <= 1 else Int.decode(args[1]) + start, end = fix_range(start, end, len(key_value)) + value = self._bytes_as_bin_string(key_value[start:end]) + + result = value.find(bit_chr) + if result != -1: + result += start if bit_mode else (start * 8) + return result @command((Key(bytes, 0),), (bytes,)) def bitcount(self, key, *args): @@ -28,10 +63,9 @@ raise SimpleError(msgs.SYNTAX_ERROR_MSG) if bit_mode: - value = key.value.decode() if key.value else '' - value = list(map(int, ''.join([bin(ord(i)).lstrip('0b').rjust(8, '0') for i in value]))) + value = self._bytes_as_bin_string(key.value if key.value else b'') start, end = fix_range_string(start, end, len(value)) - return value[start:end].count(1) + return value[start:end].count('1') start, end = fix_range_string(start, end, len(key.value)) value = key.value[start:end] @@ -51,7 +85,7 @@ @command((Key(bytes), BitOffset, BitValue)) def setbit(self, key, offset, value): - val = key.get(b'\x00') + val = key.value if key.value is not None else b'\x00' byte = offset // 8 remaining = offset % 8 actual_bitoffset = 7 - remaining @@ -68,25 +102,23 @@ old_value = value if old_byte == new_byte else 1 - value reconstructed = bytearray(val) reconstructed[byte] = new_byte - key.update(bytes(reconstructed)) + if (bytes(reconstructed) != key.value + or (self.version == 6 and old_byte != new_byte)): + key.update(bytes(reconstructed)) return old_value @staticmethod def _bitop(op, *keys): value = keys[0].value - if not isinstance(value, bytes): - raise SimpleError(msgs.WRONGTYPE_MSG) ans = keys[0].value i = 1 while i < len(keys): value = keys[i].value if keys[i].value is not None else b'' - if not isinstance(value, bytes): - raise SimpleError(msgs.WRONGTYPE_MSG) ans = bytes(op(a, b) for a, b in zip(ans, value)) i += 1 return ans - @command((bytes, Key(), Key(bytes)), (Key(bytes),)) + @command((bytes, Key()), (Key(bytes),)) def bitop(self, op_name, dst, *keys): if len(keys) == 0: raise SimpleError(msgs.WRONG_ARGS_MSG6.format('bitop')) diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/generic_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/generic_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/generic_mixin.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/generic_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -1,8 +1,9 @@ import hashlib import pickle -from random import random +import random from fakeredis import _msgs as msgs +from fakeredis._command_args_parsing import extract_args from fakeredis._commands import ( command, Key, Int, DbIndex, BeforeAny, CommandItem, SortFloat, delete_keys, key_value_type, ) @@ -163,14 +164,7 @@ @command((Key(), Int, bytes), (bytes,)) def restore(self, key, ttl, value, *args): - replace = False - i = 0 - while i < len(args): - if casematch(args[i], b'replace'): - replace = True - i += 1 - else: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) + (replace,), _ = extract_args(args, ('replace',)) if key and not replace: raise SimpleError(msgs.RESTORE_KEY_EXISTS) checksum, value = value[:20], value[20:] @@ -192,49 +186,25 @@ @command((Key(),), (bytes,)) def sort(self, key, *args): + if key.value is not None and not isinstance(key.value, (set, list, ZSet)): + raise SimpleError(msgs.WRONGTYPE_MSG) + (asc, desc, alpha, store, sortby, (limit_start, limit_count)), args = extract_args( + args, ('asc', 'desc', 'alpha', '*store', '*by', '++limit'), + error_on_unexpected=False, + left_from_first_unexpected=False, + ) + limit_start = limit_start or 0 + limit_count = -1 if limit_count is None else limit_count + dontsort = (sortby is not None and b'*' not in sortby) + i = 0 - desc = False - alpha = False - limit_start = 0 - limit_count = -1 - store = None - sortby = None - dontsort = False get = [] - if key.value is not None: - if not isinstance(key.value, (set, list, ZSet)): - raise SimpleError(msgs.WRONGTYPE_MSG) - while i < len(args): - arg = args[i] - if casematch(arg, b'asc'): - desc = False - elif casematch(arg, b'desc'): - desc = True - elif casematch(arg, b'alpha'): - alpha = True - elif casematch(arg, b'limit') and i + 2 < len(args): - try: - limit_start = Int.decode(args[i + 1]) - limit_count = Int.decode(args[i + 2]) - except SimpleError: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) - else: - i += 2 - elif casematch(arg, b'store') and i + 1 < len(args): - store = args[i + 1] - i += 1 - elif casematch(arg, b'by') and i + 1 < len(args): - sortby = args[i + 1] - if b'*' not in sortby: - dontsort = True - i += 1 - elif casematch(arg, b'get') and i + 1 < len(args): + if casematch(args[i], b'get') and i + 1 < len(args): get.append(args[i + 1]) - i += 1 + i += 2 else: raise SimpleError(msgs.SYNTAX_ERROR_MSG) - i += 1 # TODO: force sorting if the object is a set and either in Lua or # storing to a key, to match redis behaviour. diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/geo_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/geo_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/geo_mixin.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/geo_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -0,0 +1,220 @@ +import sys +from collections import namedtuple +from typing import List, Any + +from fakeredis import _msgs as msgs +from fakeredis._command_args_parsing import extract_args +from fakeredis._commands import command, Key, Float, CommandItem +from fakeredis._helpers import SimpleError +from fakeredis._zset import ZSet +from fakeredis.geo import geohash +from fakeredis.geo.haversine import distance + +UNIT_TO_M = {'km': 0.001, 'mi': 0.000621371, 'ft': 3.28084, 'm': 1} + + +def translate_meters_to_unit(unit_arg: bytes) -> float: + """number of meters in a unit. + :param unit_arg: unit name (km, mi, ft, m) + :returns: number of meters in unit + """ + unit = UNIT_TO_M.get(unit_arg.decode().lower()) + if unit is None: + raise SimpleError(msgs.GEO_UNSUPPORTED_UNIT) + return unit + + +GeoResult = namedtuple('GeoResult', 'name long lat hash distance') + + +def _parse_results( + items: List[GeoResult], + withcoord: bool, withdist: bool) -> List[Any]: + """Parse list of GeoResults to redis response + :param withcoord: include coordinates in response + :param withdist: include distance in response + :returns: Parsed list + """ + res = list() + for item in items: + new_item = [item.name, ] + if withdist: + new_item.append(Float.encode(item.distance, False)) + if withcoord: + new_item.append([Float.encode(item.long, False), + Float.encode(item.lat, False)]) + if len(new_item) == 1: + new_item = new_item[0] + res.append(new_item) + return res + + +def _find_near( + zset: ZSet, + lat: float, long: float, radius: float, + conv: float, count: int, count_any: bool, desc: bool) -> List[GeoResult]: + """Find items within area (lat,long)+radius + :param zset: list of items to check + :param lat: latitude + :param long: longitude + :param radius: radius in whatever units + :param conv: conversion of radius to meters + :param count: number of results to give + :param count_any: should we return any results that match? (vs. sorted) + :param desc: should results be sorted descending order? + :returns: List of GeoResults + """ + results = list() + for name, _hash in zset.items(): + p_lat, p_long, _, _ = geohash.decode(_hash) + dist = distance((p_lat, p_long), (lat, long)) * conv + if dist < radius: + results.append(GeoResult(name, p_long, p_lat, _hash, dist)) + if count_any and len(results) >= count: + break + results = sorted(results, key=lambda x: x.distance, reverse=desc) + if count: + results = results[:count] + return results + + +class GeoCommandsMixin: + # TODO + # GEOSEARCH, GEOSEARCHSTORE + def _store_geo_results(self, item_name: bytes, geo_results: List[GeoResult], scoredist: bool) -> int: + db_item = CommandItem(item_name, self._db, item=self._db.get(item_name), default=ZSet()) + db_item.value = ZSet() + for item in geo_results: + val = item.distance if scoredist else item.hash + db_item.value.add(item.name, val) + db_item.writeback() + return len(geo_results) + + @command(name='GEOADD', fixed=(Key(ZSet),), repeat=(bytes,)) + def geoadd(self, key, *args): + (xx, nx, ch), data = extract_args( + args, ('nx', 'xx', 'ch'), + error_on_unexpected=False, left_from_first_unexpected=True) + if xx and nx: + raise SimpleError(msgs.NX_XX_GT_LT_ERROR_MSG) + if len(data) == 0 or len(data) % 3 != 0: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + zset = key.value + old_len, changed_items = len(zset), 0 + for i in range(0, len(data), 3): + long, lat, name = Float.decode(data[i + 0]), Float.decode(data[i + 1]), data[i + 2] + if (name in zset and not xx) or (name not in zset and not nx): + if zset.add(name, geohash.encode(lat, long, 10)): + changed_items += 1 + if changed_items: + key.updated() + if ch: + return changed_items + return len(zset) - old_len + + @command(name='GEOHASH', fixed=(Key(ZSet), bytes), repeat=(bytes,)) + def geohash(self, key, *members): + hashes = map(key.value.get, members) + geohash_list = [((x + '0').encode() if x is not None else x) for x in hashes] + return geohash_list + + @command(name='GEOPOS', fixed=(Key(ZSet), bytes), repeat=(bytes,)) + def geopos(self, key, *members): + gospositions = map( + lambda x: geohash.decode(x) if x is not None else x, + map(key.value.get, members)) + res = [([self._encodefloat(x[1], humanfriendly=False), + self._encodefloat(x[0], humanfriendly=False)] + if x is not None else None) + for x in gospositions] + return res + + @command(name='GEODIST', fixed=(Key(ZSet), bytes, bytes), repeat=(bytes,)) + def geodist(self, key, m1, m2, *args): + geohashes = [key.value.get(m1), key.value.get(m2)] + if any(elem is None for elem in geohashes): + return None + geo_locs = [geohash.decode(x) for x in geohashes] + res = distance((geo_locs[0][0], geo_locs[0][1]), + (geo_locs[1][0], geo_locs[1][1])) + unit = translate_meters_to_unit(args[0]) if len(args) == 1 else 1 + return res * unit + + def _search( + self, key, long, lat, radius, conv, + withcoord, withdist, _, count, count_any, desc, store, storedist): + zset = key.value + geo_results = _find_near(zset, lat, long, radius, conv, count, count_any, desc) + + if store: + self._store_geo_results(store, geo_results, scoredist=False) + return len(geo_results) + if storedist: + self._store_geo_results(storedist, geo_results, scoredist=True) + return len(geo_results) + ret = _parse_results(geo_results, withcoord, withdist) + return ret + + @command(name='GEORADIUS_RO', fixed=(Key(ZSet), Float, Float, Float), repeat=(bytes,)) + def georadius_ro(self, key, long, lat, radius, *args): + (withcoord, withdist, withhash, count, count_any, desc), left_args = extract_args( + args, ('withcoord', 'withdist', 'withhash', '+count', 'any', 'desc',), + error_on_unexpected=False, left_from_first_unexpected=False) + count = count or sys.maxsize + conv = translate_meters_to_unit(args[0]) if len(args) >= 1 else 1 + return self._search( + key, long, lat, radius, conv, + withcoord, withdist, withhash, count, count_any, desc, False, False) + + @command(name='GEORADIUS', fixed=(Key(ZSet), Float, Float, Float), repeat=(bytes,)) + def georadius(self, key, long, lat, radius, *args): + (withcoord, withdist, withhash, count, count_any, desc, store, storedist), left_args = extract_args( + args, ('withcoord', 'withdist', 'withhash', '+count', 'any', 'desc', '*store', '*storedist'), + error_on_unexpected=False, left_from_first_unexpected=False) + count = count or sys.maxsize + conv = translate_meters_to_unit(args[0]) if len(args) >= 1 else 1 + return self._search( + key, long, lat, radius, conv, + withcoord, withdist, withhash, count, count_any, desc, store, storedist) + + @command(name='GEORADIUSBYMEMBER', fixed=(Key(ZSet), bytes, Float), repeat=(bytes,)) + def georadiusbymember(self, key, member_name, radius, *args): + member_score = key.value.get(member_name) + lat, long, _, _ = geohash.decode(member_score) + return self.georadius(key, long, lat, radius, *args) + + @command(name='GEORADIUSBYMEMBER_RO', fixed=(Key(ZSet), bytes, Float), repeat=(bytes,)) + def georadiusbymember_ro(self, key, member_name, radius, *args): + member_score = key.value.get(member_name) + lat, long, _, _ = geohash.decode(member_score) + return self.georadius_ro(key, long, lat, radius, *args) + + @command(name='GEOSEARCH', fixed=(Key(ZSet),), repeat=(bytes,)) + def geosearch(self, key, *args): + (frommember, (long, lat), radius), left_args = extract_args( + args, ('*frommember', '..fromlonlat', '.byradius'), + error_on_unexpected=False, left_from_first_unexpected=False) + if frommember is None and long is None: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if frommember is not None and long is not None: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if frommember: + return self.georadiusbymember_ro(key, frommember, radius, *left_args) + else: + return self.georadius_ro(key, long, lat, radius, *left_args) + + @command(name='GEOSEARCHSTORE', fixed=(bytes, Key(ZSet),), repeat=(bytes,)) + def geosearchstore(self, dst, src, *args): + (frommember, (long, lat), radius, storedist), left_args = extract_args( + args, ('*frommember', '..fromlonlat', '.byradius', 'storedist'), + error_on_unexpected=False, left_from_first_unexpected=False) + if frommember is None and long is None: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if frommember is not None and long is not None: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + additional = [b'storedist', dst] if storedist else [b'store', dst] + + if frommember: + return self.georadiusbymember(src, frommember, radius, *left_args, *additional) + else: + return self.georadius(src, long, lat, radius, *left_args, *additional) diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/hash_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/hash_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/hash_mixin.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/hash_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -76,11 +76,10 @@ @command((Key(Hash), bytes, bytes), (bytes, bytes)) def hset(self, key, *args): h = key.value - created = 0 - for i in range(0, len(args), 2): - if args[i] not in h: - created += 1 - h[args[i]] = args[i + 1] + keys_count = len(h.keys()) + h.update(dict(zip(*[iter(args)] * 2))) # https://stackoverflow.com/a/12739974/1056460 + created = len(h.keys()) - keys_count + key.updated() return created diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/scripting_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/scripting_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/scripting_mixin.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/scripting_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -5,7 +5,7 @@ from fakeredis import _msgs as msgs from fakeredis._commands import command, Int -from fakeredis._helpers import SimpleError, SimpleString, casenorm, OK, encode_command +from fakeredis._helpers import SimpleError, SimpleString, null_terminate, OK, encode_command LOGGER = logging.getLogger('fakeredis') REDIS_LOG_LEVELS = { @@ -210,7 +210,7 @@ @command(name='script flush', fixed=(), repeat=(bytes,), flags=msgs.FLAG_NO_SCRIPT, ) def script_flush(self, *args): - if len(args) > 1 or (len(args) == 1 and casenorm(args[0]) not in {b'sync', b'async'}): + if len(args) > 1 or (len(args) == 1 and null_terminate(args[0]) not in {b'sync', b'async'}): raise SimpleError(msgs.BAD_SUBCOMMAND_MSG.format('SCRIPT')) self.script_cache = {} return OK diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/server_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/server_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/server_mixin.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/server_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -21,17 +21,15 @@ @command((), (bytes,)) def flushdb(self, *args): - if args: - if len(args) != 1 or not casematch(args[0], b'async'): - raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if len(args) > 0 and (len(args) != 1 or not casematch(args[0], b'async')): + raise SimpleError(msgs.SYNTAX_ERROR_MSG) self._db.clear() return OK @command((), (bytes,)) def flushall(self, *args): - if args: - if len(args) != 1 or not casematch(args[0], b'async'): - raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if len(args) > 0 and (len(args) != 1 or not casematch(args[0], b'async')): + raise SimpleError(msgs.SYNTAX_ERROR_MSG) for db in self._server.dbs.values(): db.clear() # TODO: clear watches and/or pubsub as well? diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/set_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/set_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/set_mixin.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/set_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -5,38 +5,40 @@ from fakeredis._helpers import (OK, SimpleError, casematch) -class SetCommandsMixin: - # Set and Hyperloglog commands - def _setop(self, op, stop_if_missing, dst, key, *keys): - """Apply one of SINTER[STORE], SUNION[STORE], SDIFF[STORE]. - - If `stop_if_missing`, the output will be made an empty set as soon as - an empty input set is encountered (use for SINTER[STORE]). May assume - that `key` is a set (or empty), but `keys` could be anything. - """ - ans = self._calc_setop(op, stop_if_missing, key, *keys) - if dst is None: - return list(ans) - else: - dst.value = ans - return len(dst.value) - - @staticmethod - def _calc_setop(op, stop_if_missing, key, *keys): - if stop_if_missing and not key.value: - return set() - value = key.value +def _calc_setop(op, stop_if_missing, key, *keys): + if stop_if_missing and not key.value: + return set() + value = key.value + if not isinstance(value, set): + raise SimpleError(msgs.WRONGTYPE_MSG) + ans = value.copy() + for other in keys: + value = other.value if other.value is not None else set() if not isinstance(value, set): raise SimpleError(msgs.WRONGTYPE_MSG) - ans = value.copy() - for other in keys: - value = other.value if other.value is not None else set() - if not isinstance(value, set): - raise SimpleError(msgs.WRONGTYPE_MSG) - if stop_if_missing and not value: - return set() - ans = op(ans, value) - return ans + if stop_if_missing and not value: + return set() + ans = op(ans, value) + return ans + + +def _setop(op, stop_if_missing, dst, key, *keys): + """Apply one of SINTER[STORE], SUNION[STORE], SDIFF[STORE]. + + If `stop_if_missing`, the output will be made an empty set as soon as + an empty input set is encountered (use for SINTER[STORE]). May assume + that `key` is a set (or empty), but `keys` could be anything. + """ + ans = _calc_setop(op, stop_if_missing, key, *keys) + if dst is None: + return list(ans) + else: + dst.value = ans + return len(dst.value) + + +class SetCommandsMixin: + # Set and Hyperloglog commands # Set commands @command((Key(set), bytes), (bytes,)) @@ -52,15 +54,15 @@ @command((Key(set),), (Key(set),)) def sdiff(self, *keys): - return self._setop(lambda a, b: a - b, False, None, *keys) + return _setop(lambda a, b: a - b, False, None, *keys) @command((Key(), Key(set)), (Key(set),)) def sdiffstore(self, dst, *keys): - return self._setop(lambda a, b: a - b, False, dst, *keys) + return _setop(lambda a, b: a - b, False, dst, *keys) @command((Key(set),), (Key(set),)) def sinter(self, *keys): - res = self._setop(lambda a, b: a & b, True, None, *keys) + res = _setop(lambda a, b: a & b, True, None, *keys) return res @command((Int, bytes), (bytes,)) @@ -78,12 +80,12 @@ keys = [CommandItem(args[i], self._db, item=self._db.get(args[i], default=None)) for i in range(numkeys)] - res = self._setop(lambda a, b: a & b, False, None, *keys) + res = _setop(lambda a, b: a & b, False, None, *keys) return len(res) if limit == 0 else min(limit, len(res)) @command((Key(), Key(set)), (Key(set),)) def sinterstore(self, dst, *keys): - return self._setop(lambda a, b: a & b, True, dst, *keys) + return _setop(lambda a, b: a & b, True, dst, *keys) @command((Key(set), bytes)) def sismember(self, key, member): @@ -157,11 +159,11 @@ @command((Key(set),), (Key(set),)) def sunion(self, *keys): - return self._setop(lambda a, b: a | b, False, None, *keys) + return _setop(lambda a, b: a | b, False, None, *keys) @command((Key(), Key(set)), (Key(set),)) def sunionstore(self, dst, *keys): - return self._setop(lambda a, b: a | b, False, dst, *keys) + return _setop(lambda a, b: a | b, False, dst, *keys) # Hyperloglog commands # These are not quite the same as the real redis ones, which are diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/sortedset_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/sortedset_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/sortedset_mixin.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/sortedset_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -6,8 +6,9 @@ from typing import Union, Optional from fakeredis import _msgs as msgs +from fakeredis._command_args_parsing import extract_args from fakeredis._commands import (command, Key, Int, Float, CommandItem, Timeout, ScoreTest, StringTest, fix_range) -from fakeredis._helpers import (SimpleError, casematch, casenorm, ) +from fakeredis._helpers import (SimpleError, casematch, null_terminate, ) from fakeredis._zset import ZSet @@ -79,32 +80,18 @@ @command((Key(ZSet), bytes, bytes), (bytes,)) def zadd(self, key, *args): zset = key.value - ZADD_PARAMS = ['nx', 'xx', 'ch', 'incr', 'gt', 'lt', ] - param_val = {k: False for k in ZADD_PARAMS} - i = 0 - while i < len(args): - found = False - for param in ZADD_PARAMS: - if casematch(args[i], bytes(param, encoding='utf8')): - param_val[param] = True - found = True - break - if found: - i += 1 - continue - # First argument not matching flags indicates the start of - # score pairs. - break + (nx, xx, ch, incr, gt, lt), left_args = extract_args( + args, ('nx', 'xx', 'ch', 'incr', 'gt', 'lt',), error_on_unexpected=False) - if param_val['nx'] and param_val['xx']: + if nx and xx: raise SimpleError(msgs.ZADD_NX_XX_ERROR_MSG) - if [param_val['nx'], param_val['gt'], param_val['lt']].count(True) > 1: + if [nx, gt, lt].count(True) > 1: raise SimpleError(msgs.ZADD_NX_GT_LT_ERROR_MSG) - elements = args[i:] + elements = left_args if not elements or len(elements) % 2 != 0: raise SimpleError(msgs.SYNTAX_ERROR_MSG) - if param_val['incr'] and len(elements) != 2: + if incr and len(elements) != 2: raise SimpleError(msgs.ZADD_INCR_LEN_ERROR_MSG) # Parse all scores first, before updating items = [ @@ -114,20 +101,20 @@ old_len = len(zset) changed_items = 0 - if param_val['incr']: + if incr: item_score, item_name = items[0] - if (param_val['nx'] and item_name in zset) or (param_val['xx'] and item_name not in zset): + if (nx and item_name in zset) or (xx and item_name not in zset): return None return self.zincrby(key, item_score, item_name) - count = [param_val['nx'], param_val['gt'], param_val['lt'], param_val['xx']].count(True) + count = [nx, gt, lt, xx].count(True) for item_score, item_name in items: update = count == 0 - update = update or (count == 1 and param_val['nx'] and item_name not in zset) - update = update or (count == 1 and param_val['xx'] and item_name in zset) - update = update or (param_val['gt'] and ((item_name in zset and zset.get(item_name) < item_score) - or (not param_val['xx'] and item_name not in zset))) - update = update or (param_val['lt'] and ((item_name in zset and zset.get(item_name) > item_score) - or (not param_val['xx'] and item_name not in zset))) + update = update or (count == 1 and nx and item_name not in zset) + update = update or (count == 1 and xx and item_name in zset) + update = update or (gt and ((item_name in zset and zset.get(item_name) < item_score) + or (not xx and item_name not in zset))) + update = update or (lt and ((item_name in zset and zset.get(item_name) > item_score) + or (not xx and item_name not in zset))) if update: if zset.add(item_name, item_score): @@ -136,7 +123,7 @@ if changed_items: key.updated() - if param_val['ch']: + if ch: return changed_items return len(zset) - old_len @@ -169,17 +156,15 @@ def zlexcount(self, key, _min, _max): return key.value.zlexcount(_min.value, _min.exclusive, _max.value, _max.exclusive) - def _zrange(self, key, start, stop, reverse, *args): + def _zrangebyscore(self, key, _min, _max, reverse, withscores, offset, count): + zset = key.value + items = list(zset.irange_score(_min.lower_bound, _max.upper_bound, reverse=reverse)) + items = self._limit_items(items, offset, count) + items = self._apply_withscores(items, withscores) + return items + + def _zrange(self, key, start, stop, reverse, withscores, byscore): zset = key.value - withscores = False - byscore = False - for arg in args: - if casematch(arg, b'withscores'): - withscores = True - elif casematch(arg, b'byscore'): - byscore = True - else: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) if byscore: items = zset.irange_score(start.lower_bound, stop.upper_bound, reverse=reverse) else: @@ -191,66 +176,74 @@ items = self._apply_withscores(items, withscores) return items - @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,)) - def zrange(self, key, start, stop, *args): - return self._zrange(key, start, stop, False, *args) - - @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,)) - def zrevrange(self, key, start, stop, *args): - return self._zrange(key, start, stop, True, *args) - - def _zrangebylex(self, key, _min, _max, reverse, *args): - if args: - if len(args) != 3 or not casematch(args[0], b'limit'): - raise SimpleError(msgs.SYNTAX_ERROR_MSG) - offset = Int.decode(args[1]) - count = Int.decode(args[2]) - else: - offset = 0 - count = -1 + def _zrangebylex(self, key, _min, _max, reverse, offset, count): zset = key.value + if reverse: + _min, _max = _max, _min items = zset.irange_lex(_min.value, _max.value, inclusive=(not _min.exclusive, not _max.exclusive), reverse=reverse) items = self._limit_items(items, offset, count) return items + def _zrange_args(self, key, start, stop, *args): + (bylex, byscore, rev, (offset, count), withscores), _ = extract_args( + args, ('bylex', 'byscore', 'rev', '++limit', 'withscores')) + if offset is not None and not bylex and not byscore: + raise SimpleError(msgs.SYNTAX_ERROR_LIMIT_ONLY_WITH_MSG) + if bylex and byscore: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + + offset = offset or 0 + count = -1 if count is None else count + + if bylex: + res = self._zrangebylex( + key, StringTest.decode(start), StringTest.decode(stop), rev, offset, count) + elif byscore: + res = self._zrangebyscore( + key, ScoreTest.decode(start), ScoreTest.decode(stop), rev, withscores, offset, count) + else: + res = self._zrange( + key, ScoreTest.decode(start), ScoreTest.decode(stop), rev, withscores, byscore) + return res + + @command((Key(ZSet), bytes, bytes), (bytes,)) + def zrange(self, key, start, stop, *args): + return self._zrange_args(key, start, stop, *args) + + @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,)) + def zrevrange(self, key, start, stop, *args): + (withscores, byscore), _ = extract_args(args, ('withscores', 'byscore')) + return self._zrange(key, start, stop, True, withscores, byscore) + @command((Key(ZSet), StringTest, StringTest), (bytes,)) def zrangebylex(self, key, _min, _max, *args): - return self._zrangebylex(key, _min, _max, False, *args) + ((offset, count),), _ = extract_args(args, ('++limit',)) + offset = offset or 0 + count = -1 if count is None else count + return self._zrangebylex(key, _min, _max, False, offset, count) @command((Key(ZSet), StringTest, StringTest), (bytes,)) - def zrevrangebylex(self, key, _max, _min, *args): - return self._zrangebylex(key, _min, _max, True, *args) - - def _zrangebyscore(self, key, _min, _max, reverse, *args): - withscores = False - offset = 0 - count = -1 - i = 0 - while i < len(args): - if casematch(args[i], b'withscores'): - withscores = True - i += 1 - elif casematch(args[i], b'limit') and i + 2 < len(args): - offset = Int.decode(args[i + 1]) - count = Int.decode(args[i + 2]) - i += 3 - else: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) - zset = key.value - items = list(zset.irange_score(_min.lower_bound, _max.upper_bound, reverse=reverse)) - items = self._limit_items(items, offset, count) - items = self._apply_withscores(items, withscores) - return items + def zrevrangebylex(self, key, _min, _max, *args): + ((offset, count),), _ = extract_args(args, ('++limit',)) + offset = offset or 0 + count = -1 if count is None else count + return self._zrangebylex(key, _min, _max, True, offset, count) @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,)) def zrangebyscore(self, key, _min, _max, *args): - return self._zrangebyscore(key, _min, _max, False, *args) + (withscores, (offset, count)), _ = extract_args(args, ('withscores', '++limit')) + offset = offset or 0 + count = -1 if count is None else count + return self._zrangebyscore(key, _min, _max, False, withscores, offset, count) @command((Key(ZSet), ScoreTest, ScoreTest), (bytes,)) def zrevrangebyscore(self, key, _max, _min, *args): - return self._zrangebyscore(key, _min, _max, True, *args) + (withscores, (offset, count)), _ = extract_args(args, ('withscores', '++limit')) + offset = offset or 0 + count = -1 if count is None else count + return self._zrangebyscore(key, _min, _max, True, withscores, offset, count) @command((Key(ZSet), bytes)) def zrank(self, key, member): @@ -341,7 +334,7 @@ weights = [Float.decode(x) for x in args[i + 1:i + numkeys + 1]] i += numkeys + 1 elif casematch(arg, b'aggregate') and i + 1 < len(args): - aggregate = casenorm(args[i + 1]) + aggregate = null_terminate(args[i + 1]) if aggregate not in (b'sum', b'min', b'max'): raise SimpleError(msgs.SYNTAX_ERROR_MSG) i += 2 @@ -402,7 +395,7 @@ def zinterstore(self, dest, numkeys, *args): return self._zunioninter('ZINTERSTORE', dest, numkeys, *args) - @command(name="zmscore", fixed=(Key(ZSet), bytes), repeat=(bytes,)) + @command(name="ZMSCORE", fixed=(Key(ZSet), bytes), repeat=(bytes,)) def zmscore(self, key: CommandItem, *members: Union[str, bytes]) -> list[Optional[float]]: """Get the scores associated with the specified members in the sorted set stored at key. diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/streams_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/streams_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/streams_mixin.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/streams_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -0,0 +1,73 @@ +import fakeredis._msgs as msgs +from fakeredis._command_args_parsing import extract_args +from fakeredis._commands import Key, command +from fakeredis._helpers import SimpleError +from fakeredis._stream import XStream, StreamRangeTest + + +class StreamsCommandsMixin: + @command(name="XADD", fixed=(Key(),), repeat=(bytes,), ) + def xadd(self, key, *args): + + (nomkstream, limit, maxlen, minid), left_args = extract_args( + args, ('nomkstream', '+limit', '~+maxlen', '~minid'), error_on_unexpected=False) + if nomkstream and key.value is None: + return None + id_str = left_args[0] + elements = left_args[1:] + if not elements or len(elements) % 2 != 0: + raise SimpleError(msgs.WRONG_ARGS_MSG6.format('XADD')) + stream = key.value or XStream() + if self.version < 7 and id_str != b'*' and StreamRangeTest.parse_id(id_str) == (-1, -1): + raise SimpleError(msgs.XADD_INVALID_ID) + id_str = stream.add(elements, id_str=id_str) + if id_str is None: + if StreamRangeTest.parse_id(left_args[0]) == (-1, -1): + raise SimpleError(msgs.XADD_INVALID_ID) + raise SimpleError(msgs.XADD_ID_LOWER_THAN_LAST) + if maxlen is not None or minid is not None: + stream.trim(maxlen=maxlen, minid=minid, limit=limit) + key.update(stream) + return id_str + + @command(name='XTRIM', fixed=(Key(XStream),), repeat=(bytes,), ) + def xtrim(self, key, *args): + (limit, maxlen, minid), _ = extract_args( + args, ('+limit', '~+maxlen', '~minid')) + if maxlen is not None and minid is not None: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + if maxlen is None and minid is None: + raise SimpleError(msgs.SYNTAX_ERROR_MSG) + stream = key.value or XStream() + + res = stream.trim(maxlen=maxlen, minid=minid, limit=limit) + + key.update(stream) + return res + + @command(name="XLEN", fixed=(Key(XStream),)) + def xlen(self, key): + if key.value is None: + return 0 + return len(key.value) + + def _xrange(self, key, _min, _max, reverse, count, ): + if key.value is None: + return None + if count is None: + count = len(key.value) + res = key.value.irange( + _min.value, _max.value, + exclusive=(_min.exclusive, _max.exclusive), + reverse=reverse) + return res[:count] + + @command(name="XRANGE", fixed=(Key(XStream), StreamRangeTest, StreamRangeTest), repeat=(bytes,)) + def xrange(self, key, _min, _max, *args): + (count,), _ = extract_args(args, ('+count',)) + return self._xrange(key, _min, _max, False, count) + + @command(name="XREVRANGE", fixed=(Key(XStream), StreamRangeTest, StreamRangeTest), repeat=(bytes,)) + def xrevrange(self, key, _min, _max, *args): + (count,), _ = extract_args(args, ('+count',)) + return self._xrange(key, _max, _min, True, count) diff -Nru fakeredis-2.4.0/fakeredis/commands_mixins/string_mixin.py fakeredis-2.10.3/fakeredis/commands_mixins/string_mixin.py --- fakeredis-2.4.0/fakeredis/commands_mixins/string_mixin.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/commands_mixins/string_mixin.py 2023-04-03 23:14:58.068066100 +0000 @@ -1,6 +1,7 @@ import math from fakeredis import _msgs as msgs +from fakeredis._command_args_parsing import extract_args from fakeredis._commands import (command, Key, Int, Float, MAX_STRING_SIZE, delete_keys, fix_range_string) from fakeredis._helpers import (OK, SimpleError, casematch) @@ -88,7 +89,7 @@ delete_keys(key) return res - @command((Key(bytes), Int, Int)) + @command(name=['GETRANGE', 'SUBSTR'], fixed=(Key(bytes), Int, Int)) def getrange(self, key, start, end): value = key.get(b'') start, end = fix_range_string(start, end, len(value)) @@ -149,34 +150,12 @@ @command(name="set", fixed=(Key(), bytes), repeat=(bytes,)) def set_(self, key, value, *args): - i = 0 - ex, px = None, None - xx, nx, keepttl, get = False, False, False, False - while i < len(args): - if casematch(args[i], b'nx'): - nx = True - i += 1 - elif casematch(args[i], b'xx'): - xx = True - i += 1 - elif casematch(args[i], b'ex') and i + 1 < len(args): - ex = Int.decode(args[i + 1]) - if ex <= 0 or (self._db.time + ex) * 1000 >= 2 ** 63: - raise SimpleError(msgs.INVALID_EXPIRE_MSG.format('set')) - i += 2 - elif casematch(args[i], b'px') and i + 1 < len(args): - px = Int.decode(args[i + 1]) - if px <= 0 or self._db.time * 1000 + px >= 2 ** 63: - raise SimpleError(msgs.INVALID_EXPIRE_MSG.format('set')) - i += 2 - elif casematch(args[i], b'keepttl'): - keepttl = True - i += 1 - elif casematch(args[i], b'get'): - get = True - i += 1 - else: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) + (ex, px, xx, nx, keepttl, get), _ = extract_args(args, ('+ex', '+px', 'xx', 'nx', 'keepttl', 'get')) + if ex is not None and (ex <= 0 or (self._db.time + ex) * 1000 >= 2 ** 63): + raise SimpleError(msgs.INVALID_EXPIRE_MSG.format('set')) + if px is not None and (px <= 0 or self._db.time * 1000 + px >= 2 ** 63): + raise SimpleError(msgs.INVALID_EXPIRE_MSG.format('set')) + if (xx and nx) or ((px is not None) + (ex is not None) + keepttl > 1): raise SimpleError(msgs.SYNTAX_ERROR_MSG) if nx and get and self.version < 7: @@ -237,14 +216,10 @@ def strlen(self, key): return len(key.get(b'')) - # substr is a deprecated alias for getrange - @command((Key(bytes), Int, Int)) - def substr(self, key, start, end): - return self.getrange(key, start, end) - @command((Key(bytes),), (bytes,)) def getex(self, key, *args): i, count_options, expire_time, diff = 0, 0, None, None + while i < len(args): count_options += 1 if casematch(args[i], b'ex') and i + 1 < len(args): @@ -280,21 +255,8 @@ s1 = k1.value or b'' s2 = k2.value or b'' - arg_idx, arg_len, arg_minmatchlen, arg_withmatchlen = [False] * 4 - i = 0 - while i < len(args): - if casematch(args[i], b'idx'): - arg_idx = True, True - elif casematch(args[i], b'len'): - arg_len = True - elif casematch(args[i], b'minmatchlen'): - arg_minmatchlen = Int.decode(args[i + 1]) - i += 1 - elif casematch(args[i], b'withmatchlen'): - arg_withmatchlen = True - else: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) - i += 1 + (arg_idx, arg_len, arg_minmatchlen, arg_withmatchlen), _ = extract_args( + args, ('idx', 'len', '+minmatchlen', 'withmatchlen')) if arg_idx and arg_len: raise SimpleError(msgs.LCS_CANT_HAVE_BOTH_LEN_AND_IDX) lcs_len, lcs_val, matches = _lcs(s1, s2) diff -Nru fakeredis-2.4.0/fakeredis/_fakesocket.py fakeredis-2.10.3/fakeredis/_fakesocket.py --- fakeredis-2.4.0/fakeredis/_fakesocket.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/_fakesocket.py 2023-04-03 23:14:58.068066100 +0000 @@ -3,6 +3,7 @@ from .commands_mixins.bitmap_mixin import BitmapCommandsMixin from .commands_mixins.connection_mixin import ConnectionCommandsMixin from .commands_mixins.generic_mixin import GenericCommandsMixin +from .commands_mixins.geo_mixin import GeoCommandsMixin from .commands_mixins.hash_mixin import HashCommandsMixin from .commands_mixins.list_mixin import ListCommandsMixin from .commands_mixins.pubsub_mixin import PubSubCommandsMixin @@ -10,6 +11,7 @@ from .commands_mixins.server_mixin import ServerCommandsMixin from .commands_mixins.set_mixin import SetCommandsMixin from .commands_mixins.sortedset_mixin import SortedSetCommandsMixin +from .commands_mixins.streams_mixin import StreamsCommandsMixin from .commands_mixins.string_mixin import StringCommandsMixin from .commands_mixins.transactions_mixin import TransactionsCommandsMixin @@ -28,7 +30,9 @@ SetCommandsMixin, BitmapCommandsMixin, SortedSetCommandsMixin, + StreamsCommandsMixin, JSONCommandsMixin, + GeoCommandsMixin, ): def __init__(self, server): diff -Nru fakeredis-2.4.0/fakeredis/geo/geohash.py fakeredis-2.10.3/fakeredis/geo/geohash.py --- fakeredis-2.4.0/fakeredis/geo/geohash.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/fakeredis/geo/geohash.py 2023-04-03 23:14:58.068066100 +0000 @@ -0,0 +1,72 @@ +# Note: the alphabet in geohash differs from the common base32 +# alphabet described in IETF's RFC 4648 +# (http://tools.ietf.org/html/rfc4648) +from typing import Tuple + +base32 = '0123456789bcdefghjkmnpqrstuvwxyz' +decodemap = {base32[i]: i for i in range(len(base32))} + + +def decode(geohash: str) -> Tuple[float, float, float, float]: + """ + Decode the geohash to its exact values, including the error + margins of the result. Returns four float values: latitude, + longitude, the plus/minus error for latitude (as a positive + number) and the plus/minus error for longitude (as a positive + number). + """ + lat_interval, lon_interval = (-90.0, 90.0), (-180.0, 180.0) + lat_err, lon_err = 90.0, 180.0 + is_longitude = True + for c in geohash: + cd = decodemap[c] + for mask in [16, 8, 4, 2, 1]: + if is_longitude: # adds longitude info + lon_err /= 2 + if cd & mask: + lon_interval = ((lon_interval[0] + lon_interval[1]) / 2, lon_interval[1]) + else: + lon_interval = (lon_interval[0], (lon_interval[0] + lon_interval[1]) / 2) + else: # adds latitude info + lat_err /= 2 + if cd & mask: + lat_interval = ((lat_interval[0] + lat_interval[1]) / 2, lat_interval[1]) + else: + lat_interval = (lat_interval[0], (lat_interval[0] + lat_interval[1]) / 2) + is_longitude = not is_longitude + lat = (lat_interval[0] + lat_interval[1]) / 2 + lon = (lon_interval[0] + lon_interval[1]) / 2 + return lat, lon, lat_err, lon_err + + +def encode(latitude: float, longitude: float, precision=12) -> str: + """ + Encode a position given in float arguments latitude, longitude to + a geohash which will have the character count precision. + """ + lat_interval, lon_interval = (-90.0, 90.0), (-180.0, 180.0) + geohash, bits = [], [16, 8, 4, 2, 1] + bit, ch = 0, 0 + is_longitude = True + + def next_interval(curr: float, interval: Tuple[float, float], ch: int) -> Tuple[Tuple[float, float], int]: + mid = (interval[0] + interval[1]) / 2 + if curr > mid: + ch |= bits[bit] + return (mid, interval[1]), ch + else: + return (interval[0], mid), ch + + while len(geohash) < precision: + if is_longitude: + lon_interval, ch = next_interval(longitude, lon_interval, ch) + else: + lat_interval, ch = next_interval(latitude, lat_interval, ch) + is_longitude = not is_longitude + if bit < 4: + bit += 1 + else: + geohash += base32[ch] + bit = 0 + ch = 0 + return ''.join(geohash) diff -Nru fakeredis-2.4.0/fakeredis/geo/haversine.py fakeredis-2.10.3/fakeredis/geo/haversine.py --- fakeredis-2.4.0/fakeredis/geo/haversine.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/fakeredis/geo/haversine.py 2023-04-03 23:14:58.068066100 +0000 @@ -0,0 +1,34 @@ +import math +from typing import Tuple + + +# class GeoMember: +# def __init__(self, name: bytes, lat: float, long: float): +# self.name = name +# self.long = long +# self.lat = lat +# +# @staticmethod +# def from_bytes_tuple(t: Tuple[bytes, bytes, bytes]) -> 'GeoMember': +# long = Float.decode(t[0]) +# lat = Float.decode(t[1]) +# name = t[2] +# return GeoMember(name, lat, long) +# +# def geohash(self): +# return geohash.encode(self.lat, self.long) + + +def distance(origin: Tuple[float, float], destination: Tuple[float, float]) -> float: + """Calculate the Haversine distance in meters.""" + radius = 6372797.560856 # Earth's quatratic mean radius for WGS-84 + + lat1, lon1, lat2, lon2 = map( + math.radians, [origin[0], origin[1], destination[0], destination[1]]) + + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + c = 2 * math.asin(math.sqrt(a)) + + return c * radius diff -Nru fakeredis-2.4.0/fakeredis/_helpers.py fakeredis-2.10.3/fakeredis/_helpers.py --- fakeredis-2.4.0/fakeredis/_helpers.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/_helpers.py 2023-04-03 23:14:58.068066100 +0000 @@ -1,8 +1,9 @@ +from collections import defaultdict + import re import threading import time import weakref -from collections import defaultdict from collections.abc import MutableMapping @@ -37,17 +38,14 @@ def null_terminate(s): # Redis uses C functions on some strings, which means they stop at the # first NULL. - if b'\0' in s: - return s[:s.find(b'\0')] - return s - - -def casenorm(s): - return null_terminate(s).lower() + ind = s.find(b'\0') + if ind > -1: + return s[:ind].lower() + return s.lower() def casematch(a, b): - return casenorm(a) == casenorm(b) + return null_terminate(a) == null_terminate(b) def encode_command(s): @@ -201,7 +199,7 @@ if isinstance(value, NoResponse) and not nested: return True if (value is not None - and not isinstance(value, (bytes, SimpleString, SimpleError, int, list))): + and not isinstance(value, (bytes, SimpleString, SimpleError, float, int, list))): return False if isinstance(value, list): if any(not valid_response_type(item, True) for item in value): diff -Nru fakeredis-2.4.0/fakeredis/__init__.py fakeredis-2.10.3/fakeredis/__init__.py --- fakeredis-2.4.0/fakeredis/__init__.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/__init__.py 2023-04-03 23:14:58.068066100 +0000 @@ -1,7 +1,10 @@ -from ._server import FakeServer, FakeRedis, FakeStrictRedis, FakeConnection, FakeRedisConnSingleton # noqa: F401 +from ._server import FakeServer, FakeRedis, FakeStrictRedis, FakeConnection, FakeRedisConnSingleton try: from importlib import metadata except ImportError: # for Python<3.8 import importlib_metadata as metadata # type: ignore __version__ = metadata.version("fakeredis") + + +__all__ = ["FakeServer", "FakeRedis", "FakeStrictRedis", "FakeConnection", "FakeRedisConnSingleton"] diff -Nru fakeredis-2.4.0/fakeredis/_msgs.py fakeredis-2.10.3/fakeredis/_msgs.py --- fakeredis-2.4.0/fakeredis/_msgs.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/_msgs.py 2023-04-03 23:14:58.068066100 +0000 @@ -1,6 +1,8 @@ INVALID_EXPIRE_MSG = "ERR invalid expire time in {}" WRONGTYPE_MSG = "WRONGTYPE Operation against a key holding the wrong kind of value" SYNTAX_ERROR_MSG = "ERR syntax error" +SYNTAX_ERROR_LIMIT_ONLY_WITH_MSG = ( + "ERR syntax error, LIMIT is only supported in combination with either BYSCORE or BYLEX") INVALID_INT_MSG = "ERR value is not an integer or out of range" INVALID_FLOAT_MSG = "ERR value is not a valid float" INVALID_OFFSET_MSG = "ERR offset is out of range" @@ -55,6 +57,10 @@ JSON_PATH_NOT_FOUND_OR_NOT_STRING = "ERR Path '{}' does not exist or not a string" JSON_PATH_DOES_NOT_EXIST = "ERR Path '{}' does not exist" LCS_CANT_HAVE_BOTH_LEN_AND_IDX = "ERR If you want both the length and indexes, please just use IDX." +BIT_ARG_MUST_BE_ZERO_OR_ONE = "ERR The bit argument must be 1 or 0." +XADD_ID_LOWER_THAN_LAST = "The ID specified in XADD is equal or smaller than the target stream top item" +XADD_INVALID_ID = 'Invalid stream ID specified as stream command argument' FLAG_NO_SCRIPT = 's' # Command not allowed in scripts FLAG_LEAVE_EMPTY_VAL = 'v' FLAG_TRANSACTION = 't' +GEO_UNSUPPORTED_UNIT = 'unsupported unit provided. please use M, KM, FT, MI' diff -Nru fakeredis-2.4.0/fakeredis/_server.py fakeredis-2.10.3/fakeredis/_server.py --- fakeredis-2.4.0/fakeredis/_server.py 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/fakeredis/_server.py 2023-04-03 23:14:58.068066100 +0000 @@ -37,7 +37,9 @@ self.client_name = None self._sock = None self._selector = None - self._server = kwargs.pop('server') + self._server = kwargs.pop('server', None) + if self._server is None: + self._server = FakeServer() super().__init__(*args, **kwargs) def connect(self): @@ -120,10 +122,6 @@ if server is None: server = FakeServer(version=version) server.connected = connected - kwargs = { - 'connection_class': FakeConnection, - 'server': server - } conn_pool_args = { 'host', 'db', @@ -139,10 +137,12 @@ 'health_check_interval', 'client_name', } - for arg in conn_pool_args: - if arg in kwds: - kwargs[arg] = kwds[arg] - kwds['connection_pool'] = redis.connection.ConnectionPool(**kwargs) + connection_kwargs = { + 'connection_class': FakeConnection, + 'server': server + } + connection_kwargs.update({arg: kwds[arg] for arg in conn_pool_args if arg in kwds}) + kwds['connection_pool'] = redis.connection.ConnectionPool(**connection_kwargs) kwds.pop('server', None) kwds.pop('connected', None) kwds.pop('version', None) diff -Nru fakeredis-2.4.0/fakeredis/stack/__init__.py fakeredis-2.10.3/fakeredis/stack/__init__.py --- fakeredis-2.4.0/fakeredis/stack/__init__.py 2022-12-24 18:18:39.701079400 +0000 +++ fakeredis-2.10.3/fakeredis/stack/__init__.py 2023-04-03 23:14:58.068066100 +0000 @@ -2,6 +2,9 @@ from jsonpath_ng.ext import parse # noqa: F401 from redis.commands.json.path import Path # noqa: F401 from ._json_mixin import JSONCommandsMixin, JSONObject # noqa: F401 -except ImportError: +except ImportError as e: + if e.name == 'fakeredis.stack._json_mixin': + raise e + class JSONCommandsMixin: pass diff -Nru fakeredis-2.4.0/fakeredis/stack/_json_mixin.py fakeredis-2.10.3/fakeredis/stack/_json_mixin.py --- fakeredis-2.4.0/fakeredis/stack/_json_mixin.py 2022-12-24 18:18:39.701079400 +0000 +++ fakeredis-2.10.3/fakeredis/stack/_json_mixin.py 2023-04-03 23:14:58.072066300 +0000 @@ -3,20 +3,22 @@ # Future Imports from __future__ import annotations +from json import JSONDecodeError + import copy # Standard Library Imports import json -from json import JSONDecodeError -from typing import Any, Optional, Union - from jsonpath_ng import Root, JSONPath from jsonpath_ng.exceptions import JsonPathParserError from jsonpath_ng.ext import parse from redis.commands.json.commands import JsonType +from typing import Any, Optional, Union from fakeredis import _helpers as helpers, _msgs as msgs -from fakeredis._commands import Key, command, delete_keys, CommandItem +from fakeredis._command_args_parsing import extract_args +from fakeredis._commands import Key, command, delete_keys, CommandItem, Int, Float from fakeredis._helpers import SimpleError, casematch +from fakeredis._zset import ZSet def _format_path(path) -> str: @@ -62,18 +64,85 @@ def encode(cls, value: Any) -> bytes: """Serialize the supplied Python object into a valid, JSON-formatted byte-encoded string.""" - return json.dumps(value, default=str).encode() + return json.dumps(value, default=str).encode() if value is not None else None + + +def _json_write_iterate(method, key, path_str, **kwargs): + """Implement json.* write commands. + Iterate over values with path_str in key and running method to get new value for path item. + """ + if key.value is None: + raise SimpleError(msgs.JSON_KEY_NOT_FOUND) + path = _parse_jsonpath(path_str) + found_matches = path.find(key.value) + if len(found_matches) == 0: + raise SimpleError(msgs.JSON_PATH_NOT_FOUND_OR_NOT_STRING.format(path_str)) + + curr_value = copy.deepcopy(key.value) + res = list() + for item in found_matches: + new_value, res_val, update = method(item.value) + if update: + curr_value = item.full_path.update(curr_value, new_value) + res.append(res_val) + + key.update(curr_value) + + if len(path_str) > 1 and path_str[0] == ord(b'.'): + if kwargs.get('allow_result_none', False): + return res[-1] + else: + return next(x for x in reversed(res) if x is not None) + if len(res) == 1 and path_str[0] != ord(b'$'): + return res[0] + return res + + +def _json_read_iterate(method, key, *args, error_on_zero_matches=False): + path_str = args[0] if len(args) > 0 else '$' + if key.value is None: + if path_str[0] == 36: + raise SimpleError(msgs.JSON_KEY_NOT_FOUND) + else: + return None + + path = _parse_jsonpath(path_str) + found_matches = path.find(key.value) + if error_on_zero_matches and len(found_matches) == 0 and path_str[0] != 36: + raise SimpleError(msgs.JSON_PATH_NOT_FOUND_OR_NOT_STRING.format(path_str)) + res = list() + for item in found_matches: + res.append(method(item.value)) + + if path_str[0] == 46: + return res[0] if len(res) > 0 else None + if len(res) == 1 and (len(args) == 0 or (len(args) == 1 and args[0][0] == 46)): + return res[0] + + return res class JSONCommandsMixin: """`CommandsMixin` for enabling RedisJSON compatibility in `fakeredis`.""" - + NoneType = type(None) TYPES_EMPTY_VAL_DICT = { dict: {}, int: 0, float: 0.0, list: [], } + TYPE_NAMES = { + dict: b'object', + int: b'integer', + float: b'number', + bytes: b'string', + list: b'array', + set: b'set', + str: b'string', + bool: b'boolean', + NoneType: b'null', + ZSet: 'zset' + } def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -91,10 +160,6 @@ @command(name=["JSON.DEL", "JSON.FORGET"], fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) def json_del(self, key, path_str) -> int: - """Delete the JSON value stored at key `key` under `path_str`. - - For more information see `JSON.DEL `_. - """ if key.value is None: return 0 @@ -125,17 +190,7 @@ if key.value is not None and (type(key.value) is not dict) and not _path_is_root(path): raise SimpleError(msgs.JSON_WRONG_REDIS_TYPE) old_value = path.find(key.value) - nx, xx = False, False - i = 0 - while i < len(args): - if casematch(args[i], b'nx'): - nx = True - i += 1 - elif casematch(args[i], b'xx'): - xx = True - i += 1 - else: - raise SimpleError(msgs.SYNTAX_ERROR_MSG) + (nx, xx), _ = extract_args(args, ('nx', 'xx')) if xx and nx: raise SimpleError(msgs.SYNTAX_ERROR_MSG) if (nx and old_value) or (xx and not old_value): @@ -147,14 +202,8 @@ @command(name="JSON.GET", fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) def json_get(self, key, *args) -> bytes: - """Get the object stored as a JSON value at key `name`. - - `args` is zero or more paths, and defaults to root path. - - For more information see `JSON.GET `_. - """ paths = [arg for arg in args if not casematch(b'noescape', arg)] - no_wrapping_array = (len(paths) == 1 and paths[0] == b'.') + no_wrapping_array = (len(paths) == 1 and paths[0][0] == ord(b'.')) formatted_paths = [ _format_path(arg) for arg in args @@ -180,9 +229,34 @@ keys = [CommandItem(key, self._db, item=self._db.get(key), default=[]) for key in args[:-1]] - result = [self._get_single(key, path_str, empty_list_as_none=True) for key in keys] + result = [JSONObject.encode(self._get_single(key, path_str, empty_list_as_none=True)) for key in keys] return result + @command(name="JSON.TOGGLE", fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_toggle(self, key, *args): + if key.value is None: + raise SimpleError(msgs.JSON_KEY_NOT_FOUND) + path_str = args[0] if len(args) > 0 else '$' + path = _parse_jsonpath(path_str) + found_matches = path.find(key.value) + + curr_value = copy.deepcopy(key.value) + res = list() + for item in found_matches: + if type(item.value) == bool: + curr_value = item.full_path.update(curr_value, not item.value) + res.append(not item.value) + else: + res.append(None) + if all([x is None for x in res]): + raise SimpleError(msgs.JSON_KEY_NOT_FOUND) + key.update(curr_value) + + if len(res) == 1 and (len(args) == 0 or (len(args) == 1 and args[0] == b'.')): + return res[0] + + return res + @command(name="JSON.CLEAR", fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) def json_clear(self, key, *args, ): if key.value is None: @@ -201,92 +275,153 @@ key.update(curr_value) return res - @command(name="JSON.STRLEN", fixed=(Key(),), repeat=(bytes,)) - def json_strlen(self, key, *args): - """Returns the length of the JSON String at path in key + @command(name="JSON.STRAPPEND", fixed=(Key(), bytes), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_strappend(self, key, path_str, *args): + if len(args) == 0: + raise SimpleError(msgs.WRONG_ARGS_MSG6.format('json.strappend')) + addition = JSONObject.decode(args[0]) - """ - if key.value is None: - return None - path_str = args[0] if len(args) > 0 else '$' - path = _parse_jsonpath(path_str) - found_matches = path.find(key.value) - res = list() - for item in found_matches: - res.append(len(item.value) if type(item.value) == str else None) + def strappend(val): + if type(val) == str: + new_value = val + addition + return new_value, len(new_value), True + else: + return None, None, False - if len(res) == 1 and (len(args) == 0 or (len(args) == 1 and args[0] == b'.')): - return res[0] + return _json_write_iterate(strappend, key, path_str) - return res + @command(name="JSON.ARRAPPEND", fixed=(Key(), bytes,), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_arrappend(self, key, path_str, *args): + if len(args) == 0: + raise SimpleError(msgs.WRONG_ARGS_MSG6.format('json.arrappend')) - @command(name="JSON.TOGGLE", fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) - def json_toggle(self, key, *args): - """Toggle a Boolean value stored at path + addition = [JSONObject.decode(item) for item in args] - Returns an array of integer replies for each path, the new value (0 if - false or 1 if true), or nil for JSON values matching the path that are - not Boolean. + def arrappend(val): + if type(val) == list: + new_value = val + addition + return new_value, len(new_value), True + else: + return None, None, False - """ - if key.value is None: - raise SimpleError(msgs.JSON_KEY_NOT_FOUND) + return _json_write_iterate(arrappend, key, path_str) + + @command(name="JSON.ARRINSERT", fixed=(Key(), bytes, Int), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_arrinsert(self, key, path_str, index, *args): + if len(args) == 0: + raise SimpleError(msgs.WRONG_ARGS_MSG6.format('json.arrinsert')) + + addition = [JSONObject.decode(item) for item in args] + + def arrinsert(val): + if type(val) == list: + new_value = val[:index] + addition + val[index:] + return new_value, len(new_value), True + else: + return None, None, False + + return _json_write_iterate(arrinsert, key, path_str) + + @command(name="JSON.ARRPOP", fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_arrpop(self, key, *args): path_str = args[0] if len(args) > 0 else '$' - path = _parse_jsonpath(path_str) - found_matches = path.find(key.value) + index = Int.decode(args[1]) if len(args) > 1 else -1 - curr_value = copy.deepcopy(key.value) - res = list() - for item in found_matches: - if type(item.value) == bool: - curr_value = item.full_path.update(curr_value, not item.value) - res.append(not item.value) + def arrpop(val): + if type(val) == list and len(val) > 0: + ind = index if index < len(val) else -1 + res = val.pop(ind) + return val, JSONObject.encode(res), True else: - res.append(None) - if all([x is None for x in res]): - raise SimpleError(msgs.JSON_KEY_NOT_FOUND) - key.update(curr_value) + return None, None, False - if len(res) == 1 and (len(args) == 0 or (len(args) == 1 and args[0] == b'.')): - return res[0] + return _json_write_iterate(arrpop, key, path_str, allow_result_none=True) - return res + @command(name="JSON.ARRTRIM", fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_arrtrim(self, key, *args): + path_str = args[0] if len(args) > 0 else '$' + start = Int.decode(args[1]) if len(args) > 1 else 0 + stop = Int.decode(args[2]) if len(args) > 2 else None - @command(name="JSON.STRAPPEND", fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) - def json_strappend(self, key, *args): - """Append the json-string values to the string at path - - Parameters: - key: database item to change - *args: optional path + string to append + def arrtrim(val): + if type(val) == list: + start_ind = min(start, len(val)) + stop_ind = len(val) if stop is None or stop == -1 else stop + 1 + if stop_ind < 0: + stop_ind = len(val) + stop_ind + 1 + new_val = val[start_ind:stop_ind] + return new_val, len(new_val), True + else: + return None, None, False - Returns an array of integer replies for each path, the string's new - length, or nil, if the matching JSON value is not a string. - """ - if len(args) == 0: - raise SimpleError(msgs.WRONG_ARGS_MSG6.format('json.strappend')) - if key.value is None: - raise SimpleError(msgs.JSON_KEY_NOT_FOUND) + return _json_write_iterate(arrtrim, key, path_str) - path_str, addition = (args[0], args[1]) if len(args) > 1 else ('$', args[0]) - addition = JSONObject.decode(addition) - path = _parse_jsonpath(path_str) - found_matches = path.find(key.value) - if len(found_matches) == 0: - raise SimpleError(msgs.JSON_PATH_NOT_FOUND_OR_NOT_STRING.format(path_str)) + @command(name="JSON.NUMINCRBY", fixed=(Key(), bytes, Float), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_numincrby(self, key, path_str, inc_by, *args): - curr_value = copy.deepcopy(key.value) - res = list() - for item in found_matches: - if type(item.value) == str: - new_value = item.value + addition - curr_value = item.full_path.update(curr_value, new_value) - res.append(len(new_value)) + def numincrby(val): + if type(val) in {int, float}: + new_value = val + inc_by + return new_value, new_value, True else: - res.append(None) - key.update(curr_value) + return None, None, False - if len(res) == 1 and (len(args) == 1 or (len(args) > 1 and args[0] == b'.')): - return res[0] + return _json_write_iterate(numincrby, key, path_str) - return res + @command(name="JSON.NUMMULTBY", fixed=(Key(), bytes, Float), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_nummultby(self, key, path_str, mult_by, *args): + + def nummultby(val): + if type(val) in {int, float}: + new_value = val * mult_by + return new_value, new_value, True + else: + return None, None, False + + return _json_write_iterate(nummultby, key, path_str) + + # Read operations + @command(name="JSON.ARRINDEX", fixed=(Key(), bytes, bytes), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_arrindex(self, key, path_str, encoded_value, *args): + start = max(0, Int.decode(args[0]) if len(args) > 0 else 0) + end = Int.decode(args[1]) if len(args) > 1 else -1 + end = end if end > 0 else -1 + expected_value = JSONObject.decode(encoded_value) + + def check_index(value): + if type(value) != list: + return None + try: + ind = next(filter( + lambda x: x[1] == expected_value and type(x[1]) == type(expected_value), + enumerate(value[start:end]))) + return ind[0] + start + except StopIteration: + return -1 + + return _json_read_iterate(check_index, key, path_str, *args, error_on_zero_matches=True) + + @command(name="JSON.STRLEN", fixed=(Key(),), repeat=(bytes,)) + def json_strlen(self, key, *args): + return _json_read_iterate( + lambda val: len(val) if type(val) == str else None, key, *args) + + @command(name="JSON.ARRLEN", fixed=(Key(),), repeat=(bytes,)) + def json_arrlen(self, key, *args): + return _json_read_iterate( + lambda val: len(val) if type(val) == list else None, key, *args) + + @command(name="JSON.OBJLEN", fixed=(Key(),), repeat=(bytes,)) + def json_objlen(self, key, *args): + return _json_read_iterate( + lambda val: len(val) if type(val) == dict else None, key, *args) + + @command(name="JSON.TYPE", fixed=(Key(),), repeat=(bytes,), flags=msgs.FLAG_LEAVE_EMPTY_VAL) + def json_type(self, key, *args, ): + return _json_read_iterate( + lambda val: self.TYPE_NAMES.get(type(val), None), key, *args) + + @command(name="JSON.OBJKEYS", fixed=(Key(),), repeat=(bytes,)) + def json_objkeys(self, key, *args): + return _json_read_iterate( + lambda val: [i.encode() for i in val.keys()] if type(val) == dict else None, key, *args) diff -Nru fakeredis-2.4.0/fakeredis/_stream.py fakeredis-2.10.3/fakeredis/_stream.py --- fakeredis-2.4.0/fakeredis/_stream.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/fakeredis/_stream.py 2023-04-03 23:14:58.068066100 +0000 @@ -0,0 +1,127 @@ +import bisect +import time +from typing import List, Union, Tuple, Optional + +from fakeredis._commands import BeforeAny, AfterAny + + +class StreamRangeTest: + """Argument converter for sorted set LEX endpoints.""" + + def __init__(self, value, exclusive): + self.value = value + self.exclusive = exclusive + + @staticmethod + def parse_id(id_str: str): + if isinstance(id_str, bytes): + id_str = id_str.decode() + try: + timestamp, sequence = (int(x) for x in id_str.split('-')) + except ValueError: + return -1, -1 + return timestamp, sequence + + @classmethod + def decode(cls, value): + if value == b'-': + return cls(BeforeAny(), True) + elif value == b'+': + return cls(AfterAny(), True) + elif value[:1] == b'(': + return cls(cls.parse_id(value[1:]), True) + return cls(cls.parse_id(value), False) + + +class XStream: + def __init__(self): + # Values: + # [ + # ((timestamp,sequence), [field1, value1, field2, value2, ...]) + # ((timestamp,sequence), [field1, value1, field2, value2, ...]) + # ] + self._values = list() + + def add(self, fields: List, id_str: str = '*') -> Union[None, bytes]: + assert len(fields) % 2 == 0 + if isinstance(id_str, bytes): + id_str = id_str.decode() + + if id_str is None or id_str == '*': + ts, seq = int(time.time() + 1), 0 + if (len(self._values) > 0 + and self._values[-1][0][0] == ts + and self._values[-1][0][1] >= seq): + seq = self._values[-1][0][1] + 1 + ts_seq = (ts, seq) + elif id_str[-1] == '*': + split = id_str.split('-') + if len(split) != 2: + return None + ts, seq = int(split[0]), split[1] + if len(self._values) > 0 and ts == self._values[-1][0][0]: + seq = self._values[-1][0][1] + 1 + else: + seq = 0 + ts_seq = (ts, seq) + else: + ts_seq = StreamRangeTest.parse_id(id_str) + + if len(self._values) > 0 and self._values[-1][0] > ts_seq: + return None + new_val = (ts_seq, list(fields)) + self._values.append(new_val) + return f'{ts_seq[0]}-{ts_seq[1]}'.encode() + + def __len__(self): + return len(self._values) + + def __iter__(self): + def gen(): + for record in self._values: + yield self._format_record(record) + + return gen() + + def find_index(self, id_str: str) -> Tuple[int, bool]: + ts_seq = StreamRangeTest.parse_id(id_str) + ind = bisect.bisect_left(list(map(lambda x: x[0], self._values)), ts_seq) + return ind, self._values[ind][0] == ts_seq + + @staticmethod + def _format_record(record): + results = list(record[1:][0]) + return [f'{record[0][0]}-{record[0][1]}'.encode(), results] + + def trim(self, + maxlen: Optional[int] = None, + minid: Optional[str] = None, + limit: Optional[int] = None) -> int: + if maxlen is not None and minid is not None: + raise + start_ind = None + if maxlen is not None: + start_ind = len(self._values) - maxlen + elif minid is not None: + ind, exact = self.find_index(minid) + start_ind = ind + res = max(start_ind, 0) + if limit is not None: + res = min(start_ind, limit) + self._values = self._values[res:] + return res + + def irange(self, + start, stop, + exclusive: Tuple[bool, bool] = (True, True), + reverse=False): + def match(record): + result = stop > record[0] > start + result = result or (not exclusive[0] and record[0] == start) + result = result or (not exclusive[1] and record[0] == stop) + return result + + matches = map(self._format_record, filter(match, self._values)) + if reverse: + return list(reversed(tuple(matches))) + return list(matches) diff -Nru fakeredis-2.4.0/PKG-INFO fakeredis-2.10.3/PKG-INFO --- fakeredis-2.4.0/PKG-INFO 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/PKG-INFO 1970-01-01 00:00:00.000000000 +0000 @@ -1,15 +1,15 @@ Metadata-Version: 2.1 Name: fakeredis -Version: 2.4.0 +Version: 2.10.3 Summary: Fake implementation of redis API for testing purposes. Home-page: https://github.com/cunla/fakeredis-py License: BSD-3-Clause -Keywords: redis,rq,django-rq,RedisJson -Author: James Saryerwinnie -Author-email: js@jamesls.com +Keywords: redis,RedisJson +Author: Daniel Moran +Author-email: daniel.maruani@gmail.com Maintainer: Daniel Moran Maintainer-email: daniel.maruani@gmail.com -Requires-Python: >=3.8.1,<4.0 +Requires-Python: >=3.7,<4.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Development Status :: 6 - Mature Classifier: Intended Audience :: Developers @@ -17,6 +17,8 @@ Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 @@ -29,14 +31,17 @@ Provides-Extra: lua Requires-Dist: jsonpath-ng (>=1.5,<2.0) ; extra == "json" Requires-Dist: lupa (>=1.14,<2.0) ; extra == "lua" -Requires-Dist: redis (<4.5) -Requires-Dist: sortedcontainers (>=2.4.0,<3.0.0) +Requires-Dist: redis (>=4) +Requires-Dist: sortedcontainers (>=2.4,<3.0) Project-URL: Bug Tracker, https://github.com/cunla/fakeredis-py/issues +Project-URL: Documentation, https://fakeredis.readthedocs.io/ +Project-URL: Funding, https://github.com/sponsors/cunla Project-URL: Repository, https://github.com/cunla/fakeredis-py Description-Content-Type: text/markdown fakeredis: A fake version of a redis-py ======================================= + [![badge](https://img.shields.io/pypi/v/fakeredis)](https://pypi.org/project/fakeredis/) [![CI](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml/badge.svg)](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml) [![badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/cunla/b756396efb895f0e34558c980f1ca0c7/raw/fakeredis-py.json)](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml) @@ -44,346 +49,18 @@ [![badge](https://img.shields.io/pypi/l/fakeredis)](./LICENSE) [![Open Source Helpers](https://www.codetriage.com/cunla/fakeredis-py/badges/users.svg)](https://www.codetriage.com/cunla/fakeredis-py) -------------------- -[Intro](#intro) | [How to Use](#how-to-use) | [Contributing](.github/CONTRIBUTING.md) | [Guides](#guides) -| [Sponsoring](#sponsor) + +Documentation is now hosted in https://fakeredis.readthedocs.io/ # Intro fakeredis is a pure-Python implementation of the redis-py python client -that simulates talking to a redis server. This was created for a single -purpose: **to write tests**. Setting up redis is not hard, but -many times you want to write tests that do not talk to an external server -(such as redis). This module now allows tests to simply use this -module as a reasonable substitute for redis. - -For a list of supported/unsupported redis commands, see [REDIS_COMMANDS.md](./REDIS_COMMANDS.md). - -# Installation - -To install fakeredis-py, simply: +that simulates talking to a redis server. -```bash -pip install fakeredis # No additional modules support +This was created originally for a single purpose: **to write tests**. -pip install fakeredis[lua] # Support for LUA scripts - -pip install fakeredis[json] # Support for RedisJSON commands -``` - -# How to Use - -FakeRedis can imitate Redis server version 6.x or 7.x. -If you do not specify the version, version 7 is used by default. - -The intent is for fakeredis to act as though you're talking to a real -redis server. It does this by storing state internally. -For example: - -```pycon ->>> import fakeredis ->>> r = fakeredis.FakeStrictRedis(version=6) ->>> r.set('foo', 'bar') -True ->>> r.get('foo') -'bar' ->>> r.lpush('bar', 1) -1 ->>> r.lpush('bar', 2) -2 ->>> r.lrange('bar', 0, -1) -[2, 1] -``` - -The state is stored in an instance of `FakeServer`. If one is not provided at -construction, a new instance is automatically created for you, but you can -explicitly create one to share state: - -```pycon ->>> import fakeredis ->>> server = fakeredis.FakeServer() ->>> r1 = fakeredis.FakeStrictRedis(server=server) ->>> r1.set('foo', 'bar') -True ->>> r2 = fakeredis.FakeStrictRedis(server=server) ->>> r2.get('foo') -'bar' ->>> r2.set('bar', 'baz') -True ->>> r1.get('bar') -'baz' ->>> r2.get('bar') -'baz' -``` - -It is also possible to mock connection errors, so you can effectively test -your error handling. Simply set the connected attribute of the server to -`False` after initialization. - -```pycon ->>> import fakeredis ->>> server = fakeredis.FakeServer() ->>> server.connected = False ->>> r = fakeredis.FakeStrictRedis(server=server) ->>> r.set('foo', 'bar') -ConnectionError: FakeRedis is emulating a connection error. ->>> server.connected = True ->>> r.set('foo', 'bar') -True -``` - -Fakeredis implements the same interface as `redis-py`, the popular -redis client for python, and models the responses of redis 6.x or 7.x. - -## Use to test django-rq - -There is a need to override `django_rq.queues.get_redis_connection` with -a method returning the same connection. - -```python -from fakeredis import FakeRedisConnSingleton - -django_rq.queues.get_redis_connection = FakeRedisConnSingleton() -``` - -## Support for additional modules - -### RedisJson - -Currently, Redis Json module is partially implemented ( -see [supported commands](https://github.com/cunla/fakeredis-py/blob/master/REDIS_COMMANDS.md#json)). - -```pycon ->>> import fakeredis ->>> from redis.commands.json.path import Path ->>> r = fakeredis.FakeStrictRedis() ->>> assert r.json().set("foo", Path.root_path(), {"x": "bar"}, ) == 1 ->>> r.json().get("foo") -{'x': 'bar'} ->>> r.json().get("foo", Path("x")) -'bar' -``` - -### Lua support - -If you wish to have Lua scripting support (this includes features like ``redis.lock.Lock``, which are implemented in -Lua), you will need [lupa](https://pypi.org/project/lupa/), you can simply install it using `pip install fakeredis[lua]` - -### JSON support - -Support for JSON commands (eg, [`JSON.GET`](https://redis.io/commands/json.get/)) is implemented using -[jsonpath-ng](https://github.com/h2non/jsonpath-ng), you can simply install it using `pip install fakeredis[json]`. - -## Known Limitations - -Apart from unimplemented commands, there are a number of cases where fakeredis -won't give identical results to real redis. The following are differences that -are unlikely to ever be fixed; there are also differences that are fixable -(such as commands that do not support all features) which should be filed as -bugs in GitHub. - -- Hyperloglogs are implemented using sets underneath. This means that the - `type` command will return the wrong answer, you can't use `get` to retrieve - the encoded value, and counts will be slightly different (they will in fact be - exact). -- When a command has multiple error conditions, such as operating on a key of - the wrong type and an integer argument is not well-formed, the choice of - error to return may not match redis. - -- The `incrbyfloat` and `hincrbyfloat` commands in redis use the C `long - double` type, which typically has more precision than Python's `float` - type. - -- Redis makes guarantees about the order in which clients blocked on blocking - commands are woken up. Fakeredis does not honour these guarantees. - -- Where redis contains bugs, fakeredis generally does not try to provide exact - bug-compatibility. It's not practical for fakeredis to try to match the set - of bugs in your specific version of redis. - -- There are a number of cases where the behaviour of redis is undefined, such - as the order of elements returned by set and hash commands. Fakeredis will - generally not produce the same results, and in Python versions before 3.6 - may produce different results each time the process is re-run. - -- SCAN/ZSCAN/HSCAN/SSCAN will not necessarily iterate all items if items are - deleted or renamed during iteration. They also won't necessarily iterate in - the same chunk sizes or the same order as redis. - -- DUMP/RESTORE will not return or expect data in the RDB format. Instead, the - `pickle` module is used to mimic an opaque and non-standard format. - **WARNING**: Do not use RESTORE with untrusted data, as a malicious pickle - can execute arbitrary code. - --------------------- - -# Local development environment - -To ensure parity with the real redis, there are a set of integration tests -that mirror the unittests. For every unittest that is written, the same -test is run against a real redis instance using a real redis-py client -instance. In order to run these tests you must have a redis server running -on localhost, port 6379 (the default settings). **WARNING**: the tests will -completely wipe your database! - -First install poetry if you don't have it, and then install all the dependencies: - -```bash -pip install poetry -poetry install -``` - -To run all the tests: - -```bash -poetry run pytest -v -``` - -If you only want to run tests against fake redis, without a real redis:: - -```bash -poetry run pytest -m fake -``` - -Because this module is attempting to provide the same interface as `redis-py`, -the python bindings to redis, a reasonable way to test this to take each -unittest and run it against a real redis server. fakeredis and the real redis -server should give the same result. To run tests against a real redis instance -instead: - -```bash -poetry run pytest -m real -``` - -If redis is not running, and you try to run tests against a real redis server, -these tests will have a result of 's' for skipped. - -There are some tests that test redis blocking operations that are somewhat -slow. If you want to skip these tests during day to day development, -they have all been tagged as 'slow' so you can skip them by running: - -```bash -poetry run pytest -m "not slow" -``` - -# Contributing - -Contributions are welcome. -You can contribute in many ways: -Open issues for bugs you found, implementing a command which is not yet implemented, -implement a test for scenario that is not covered yet, write a guide how to use fakeredis, etc. - -Please see the [contributing guide](.github/CONTRIBUTING.md) for more details. -If you'd like to help out, you can start with any of the issues labeled with `Help wanted`. - -There are guides how to [implement a new command](#implementing-support-for-a-command) and -how to [write new test cases](#write-a-new-test-case). - -New contribution guides are welcome. - -# Guides - -### Implementing support for a command - -Creating a new command support should be done in the `FakeSocket` class (in `_fakesocket.py`) by creating the method -and using `@command` decorator (which should be the command syntax, you can use existing samples on the file). - -For example: - -```python -class FakeSocket(BaseFakeSocket, FakeLuaSocket): - # ... - @command(name='zscore', fixed=(Key(ZSet), bytes), repeat=(), flags=[]) - def zscore(self, key, member): - try: - return self._encodefloat(key.value[member], False) - except KeyError: - return None -``` - -#### How to use `@command` decorator - -The `@command` decorator register the method as a redis command and define the accepted format for it. -It will create a `Signature` instance for the command. Whenever the command is triggered, the `Signature.apply(..)` -method will be triggered to check the validity of syntax and analyze the command arguments. - -By default, it takes the name of the method as the command name. - -If the method implements a subcommand (eg, `SCRIPT LOAD`), a Redis module command (eg, `JSON.GET`), -or a python reserve word where you can not use it as the method name (eg, `EXEC`), then you can supply -explicitly the name parameter. - -If the command implemented require certain arguments, they can be supplied in the first parameter as a tuple. -When receiving the command through the socket, the bytes will be converted to the argument types -supplied or remain as `bytes`. - -Argument types (All in `_commands.py`): - -- `Key(KeyType)` - Will get from the DB the key and validate its value is of `KeyType` (if `KeyType` is supplied). - It will generate a `CommandItem` from it which provides access to the database value. -- `Int` - Decode the `bytes` to `int` and vice versa. -- `DbIndex`/`BitOffset`/`BitValue`/`Timeout` - Basically the same behavior as `Int`, but with different messages when - encode/decode fail. -- `Hash` - dictionary, usually describe the type of value stored in Key `Key(Hash)` -- `Float` - Encode/Decode `bytes` <-> `float` -- `SortFloat` - Similar to `Float` with different error messages. -- `ScoreTest` - Argument converter for sorted set score endpoints. -- `StringTest` - Argument converter for sorted set endpoints (lex). -- `ZSet` - Sorted Set. - -#### Implement a test for it - -There are multiple scenarios for test, with different versions of redis server, redis-py, etc. -The tests not only assert the validity of output but runs the same test on a real redis-server and compares the output -to the real server output. - -- Create tests in the relevant test file. -- If support for the command was introduced in a certain version of redis-py ( - see [redis-py release notes](https://github.com/redis/redis-py/releases/tag/v4.3.4)) you can use the - decorator `@testtools.run_test_if_redispy_ver` on your tests. example: - -```python -@testtools.run_test_if_redispy_ver('above', '4.2.0') # This will run for redis-py 4.2.0 or above. -def test_expire_should_not_expire__when_no_expire_is_set(r): - r.set('foo', 'bar') - assert r.get('foo') == b'bar' - assert r.expire('foo', 1, xx=True) == 0 -``` - -#### Updating `REDIS_COMMANDS.md` - -Lastly, run from the root of the project the script to regenerate `REDIS_COMMANDS.md`: - -```bash -python scripts/supported.py > REDIS_COMMANDS.md -``` - -### Write a new test case - -There are multiple scenarios for test, with different versions of python, redis-py and redis server, etc. -The tests not only assert the validity of the expected output with FakeRedis but also with a real redis server. -That way parity of real Redis and FakeRedis is ensured. - -To write a new test case for a command: - -- Determine which mixin the command belongs to and the test file for - the mixin (eg, `string_mixin.py` => `test_string_commands.py`). -- Tests should support python 3.7 and above. -- Determine when support for the command was introduced - - To limit the redis-server versions it will run on use: - `@pytest.mark.max_server(version)` and `@pytest.mark.min_server(version)` - - To limit the redis-py version use `@run_test_if_redispy_ver(above/below, version)` -- pytest will inject a redis connection to the argument `r` of the test. - -Sample of running a test for redis-py v4.2.0 and above, redis-server 7.0 and above. - -```python -@pytest.mark.min_server('7') -@testtools.run_test_if_redispy_ver('above', '4.2.0') -def test_expire_should_not_expire__when_no_expire_is_set(r): - r.set('foo', 'bar') - assert r.get('foo') == b'bar' - assert r.expire('foo', 1, xx=True) == 0 -``` +This module now allows tests to simply use this +module as a reasonable substitute for redis. # Sponsor @@ -391,6 +68,9 @@ You can support this project by becoming a sponsor using [this link](https://github.com/sponsors/cunla). -Alternatively, you can buy me coffee using this -link: [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/danielmoran) +## Security contact information + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. diff -Nru fakeredis-2.4.0/pyproject.toml fakeredis-2.10.3/pyproject.toml --- fakeredis-2.4.0/pyproject.toml 2022-12-24 18:18:39.701079400 +0000 +++ fakeredis-2.10.3/pyproject.toml 2023-04-03 23:14:58.072066300 +0000 @@ -8,14 +8,14 @@ packages = [ { include = "fakeredis" }, ] -version = "2.4.0" +version = "2.10.3" description = "Fake implementation of redis API for testing purposes." readme = "README.md" -keywords = ["redis", "rq", "django-rq", "RedisJson", ] +keywords = ["redis", "RedisJson", ] authors = [ - "James Saryerwinnie ", + "Daniel Moran ", "Bruce Merry ", - "Daniel Moran " + "James Saryerwinnie ", ] maintainers = [ "Daniel Moran ", @@ -36,11 +36,15 @@ ] homepage = "https://github.com/cunla/fakeredis-py" repository = "https://github.com/cunla/fakeredis-py" +documentation = "https://fakeredis.readthedocs.io/" +include = [ + { path = "test", format = "sdist" }, +] [tool.poetry.dependencies] -python = ">=3.8.1,<4.0" -redis = "<4.5" -sortedcontainers = "^2.4.0" +python = "^3.7" +redis = ">=4" +sortedcontainers = "^2.4" lupa = { version = "^1.14", optional = true } jsonpath-ng = { version = "^1.5", optional = true } @@ -49,21 +53,23 @@ json = ["jsonpath-ng"] [tool.poetry.dev-dependencies] -invoke = "^1.7" -hypothesis = "^6.56" -tox = "^4.0.11" -twine = "^4.0" +hypothesis = "^6.70" coverage = "^7" pytest = "^7.2" -pytest-asyncio = "^0.20" +pytest-asyncio = "^0.21" pytest-cov = "^4.0" pytest-mock = "^3.10" -flake8 = "^6.0" -mypy = "^0.991" -types-redis = "^4.3" +flake8 = { version = "^6.0", python = ">=3.8.1" } +mypy = "^1" +types-redis = ">=4.0" +twine = "^4.0" # Upload to pypi +tox = "^4.4" +tox-docker = "^4" [tool.poetry.urls] "Bug Tracker" = "https://github.com/cunla/fakeredis-py/issues" +"Funding" = "https://github.com/sponsors/cunla" + [tool.pytest.ini_options] markers = [ @@ -85,4 +91,4 @@ packages = ['fakeredis', ] follow_imports = "silent" ignore_missing_imports = true -scripts_are_modules = true \ No newline at end of file +scripts_are_modules = true diff -Nru fakeredis-2.4.0/README.md fakeredis-2.10.3/README.md --- fakeredis-2.4.0/README.md 2022-12-24 18:18:39.697079400 +0000 +++ fakeredis-2.10.3/README.md 2023-04-03 23:14:58.068066100 +0000 @@ -1,5 +1,6 @@ fakeredis: A fake version of a redis-py ======================================= + [![badge](https://img.shields.io/pypi/v/fakeredis)](https://pypi.org/project/fakeredis/) [![CI](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml/badge.svg)](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml) [![badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/cunla/b756396efb895f0e34558c980f1ca0c7/raw/fakeredis-py.json)](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml) @@ -7,346 +8,18 @@ [![badge](https://img.shields.io/pypi/l/fakeredis)](./LICENSE) [![Open Source Helpers](https://www.codetriage.com/cunla/fakeredis-py/badges/users.svg)](https://www.codetriage.com/cunla/fakeredis-py) -------------------- -[Intro](#intro) | [How to Use](#how-to-use) | [Contributing](.github/CONTRIBUTING.md) | [Guides](#guides) -| [Sponsoring](#sponsor) + +Documentation is now hosted in https://fakeredis.readthedocs.io/ # Intro fakeredis is a pure-Python implementation of the redis-py python client -that simulates talking to a redis server. This was created for a single -purpose: **to write tests**. Setting up redis is not hard, but -many times you want to write tests that do not talk to an external server -(such as redis). This module now allows tests to simply use this -module as a reasonable substitute for redis. - -For a list of supported/unsupported redis commands, see [REDIS_COMMANDS.md](./REDIS_COMMANDS.md). - -# Installation - -To install fakeredis-py, simply: +that simulates talking to a redis server. -```bash -pip install fakeredis # No additional modules support +This was created originally for a single purpose: **to write tests**. -pip install fakeredis[lua] # Support for LUA scripts - -pip install fakeredis[json] # Support for RedisJSON commands -``` - -# How to Use - -FakeRedis can imitate Redis server version 6.x or 7.x. -If you do not specify the version, version 7 is used by default. - -The intent is for fakeredis to act as though you're talking to a real -redis server. It does this by storing state internally. -For example: - -```pycon ->>> import fakeredis ->>> r = fakeredis.FakeStrictRedis(version=6) ->>> r.set('foo', 'bar') -True ->>> r.get('foo') -'bar' ->>> r.lpush('bar', 1) -1 ->>> r.lpush('bar', 2) -2 ->>> r.lrange('bar', 0, -1) -[2, 1] -``` - -The state is stored in an instance of `FakeServer`. If one is not provided at -construction, a new instance is automatically created for you, but you can -explicitly create one to share state: - -```pycon ->>> import fakeredis ->>> server = fakeredis.FakeServer() ->>> r1 = fakeredis.FakeStrictRedis(server=server) ->>> r1.set('foo', 'bar') -True ->>> r2 = fakeredis.FakeStrictRedis(server=server) ->>> r2.get('foo') -'bar' ->>> r2.set('bar', 'baz') -True ->>> r1.get('bar') -'baz' ->>> r2.get('bar') -'baz' -``` - -It is also possible to mock connection errors, so you can effectively test -your error handling. Simply set the connected attribute of the server to -`False` after initialization. - -```pycon ->>> import fakeredis ->>> server = fakeredis.FakeServer() ->>> server.connected = False ->>> r = fakeredis.FakeStrictRedis(server=server) ->>> r.set('foo', 'bar') -ConnectionError: FakeRedis is emulating a connection error. ->>> server.connected = True ->>> r.set('foo', 'bar') -True -``` - -Fakeredis implements the same interface as `redis-py`, the popular -redis client for python, and models the responses of redis 6.x or 7.x. - -## Use to test django-rq - -There is a need to override `django_rq.queues.get_redis_connection` with -a method returning the same connection. - -```python -from fakeredis import FakeRedisConnSingleton - -django_rq.queues.get_redis_connection = FakeRedisConnSingleton() -``` - -## Support for additional modules - -### RedisJson - -Currently, Redis Json module is partially implemented ( -see [supported commands](https://github.com/cunla/fakeredis-py/blob/master/REDIS_COMMANDS.md#json)). - -```pycon ->>> import fakeredis ->>> from redis.commands.json.path import Path ->>> r = fakeredis.FakeStrictRedis() ->>> assert r.json().set("foo", Path.root_path(), {"x": "bar"}, ) == 1 ->>> r.json().get("foo") -{'x': 'bar'} ->>> r.json().get("foo", Path("x")) -'bar' -``` - -### Lua support - -If you wish to have Lua scripting support (this includes features like ``redis.lock.Lock``, which are implemented in -Lua), you will need [lupa](https://pypi.org/project/lupa/), you can simply install it using `pip install fakeredis[lua]` - -### JSON support - -Support for JSON commands (eg, [`JSON.GET`](https://redis.io/commands/json.get/)) is implemented using -[jsonpath-ng](https://github.com/h2non/jsonpath-ng), you can simply install it using `pip install fakeredis[json]`. - -## Known Limitations - -Apart from unimplemented commands, there are a number of cases where fakeredis -won't give identical results to real redis. The following are differences that -are unlikely to ever be fixed; there are also differences that are fixable -(such as commands that do not support all features) which should be filed as -bugs in GitHub. - -- Hyperloglogs are implemented using sets underneath. This means that the - `type` command will return the wrong answer, you can't use `get` to retrieve - the encoded value, and counts will be slightly different (they will in fact be - exact). -- When a command has multiple error conditions, such as operating on a key of - the wrong type and an integer argument is not well-formed, the choice of - error to return may not match redis. - -- The `incrbyfloat` and `hincrbyfloat` commands in redis use the C `long - double` type, which typically has more precision than Python's `float` - type. - -- Redis makes guarantees about the order in which clients blocked on blocking - commands are woken up. Fakeredis does not honour these guarantees. - -- Where redis contains bugs, fakeredis generally does not try to provide exact - bug-compatibility. It's not practical for fakeredis to try to match the set - of bugs in your specific version of redis. - -- There are a number of cases where the behaviour of redis is undefined, such - as the order of elements returned by set and hash commands. Fakeredis will - generally not produce the same results, and in Python versions before 3.6 - may produce different results each time the process is re-run. - -- SCAN/ZSCAN/HSCAN/SSCAN will not necessarily iterate all items if items are - deleted or renamed during iteration. They also won't necessarily iterate in - the same chunk sizes or the same order as redis. - -- DUMP/RESTORE will not return or expect data in the RDB format. Instead, the - `pickle` module is used to mimic an opaque and non-standard format. - **WARNING**: Do not use RESTORE with untrusted data, as a malicious pickle - can execute arbitrary code. - --------------------- - -# Local development environment - -To ensure parity with the real redis, there are a set of integration tests -that mirror the unittests. For every unittest that is written, the same -test is run against a real redis instance using a real redis-py client -instance. In order to run these tests you must have a redis server running -on localhost, port 6379 (the default settings). **WARNING**: the tests will -completely wipe your database! - -First install poetry if you don't have it, and then install all the dependencies: - -```bash -pip install poetry -poetry install -``` - -To run all the tests: - -```bash -poetry run pytest -v -``` - -If you only want to run tests against fake redis, without a real redis:: - -```bash -poetry run pytest -m fake -``` - -Because this module is attempting to provide the same interface as `redis-py`, -the python bindings to redis, a reasonable way to test this to take each -unittest and run it against a real redis server. fakeredis and the real redis -server should give the same result. To run tests against a real redis instance -instead: - -```bash -poetry run pytest -m real -``` - -If redis is not running, and you try to run tests against a real redis server, -these tests will have a result of 's' for skipped. - -There are some tests that test redis blocking operations that are somewhat -slow. If you want to skip these tests during day to day development, -they have all been tagged as 'slow' so you can skip them by running: - -```bash -poetry run pytest -m "not slow" -``` - -# Contributing - -Contributions are welcome. -You can contribute in many ways: -Open issues for bugs you found, implementing a command which is not yet implemented, -implement a test for scenario that is not covered yet, write a guide how to use fakeredis, etc. - -Please see the [contributing guide](.github/CONTRIBUTING.md) for more details. -If you'd like to help out, you can start with any of the issues labeled with `Help wanted`. - -There are guides how to [implement a new command](#implementing-support-for-a-command) and -how to [write new test cases](#write-a-new-test-case). - -New contribution guides are welcome. - -# Guides - -### Implementing support for a command - -Creating a new command support should be done in the `FakeSocket` class (in `_fakesocket.py`) by creating the method -and using `@command` decorator (which should be the command syntax, you can use existing samples on the file). - -For example: - -```python -class FakeSocket(BaseFakeSocket, FakeLuaSocket): - # ... - @command(name='zscore', fixed=(Key(ZSet), bytes), repeat=(), flags=[]) - def zscore(self, key, member): - try: - return self._encodefloat(key.value[member], False) - except KeyError: - return None -``` - -#### How to use `@command` decorator - -The `@command` decorator register the method as a redis command and define the accepted format for it. -It will create a `Signature` instance for the command. Whenever the command is triggered, the `Signature.apply(..)` -method will be triggered to check the validity of syntax and analyze the command arguments. - -By default, it takes the name of the method as the command name. - -If the method implements a subcommand (eg, `SCRIPT LOAD`), a Redis module command (eg, `JSON.GET`), -or a python reserve word where you can not use it as the method name (eg, `EXEC`), then you can supply -explicitly the name parameter. - -If the command implemented require certain arguments, they can be supplied in the first parameter as a tuple. -When receiving the command through the socket, the bytes will be converted to the argument types -supplied or remain as `bytes`. - -Argument types (All in `_commands.py`): - -- `Key(KeyType)` - Will get from the DB the key and validate its value is of `KeyType` (if `KeyType` is supplied). - It will generate a `CommandItem` from it which provides access to the database value. -- `Int` - Decode the `bytes` to `int` and vice versa. -- `DbIndex`/`BitOffset`/`BitValue`/`Timeout` - Basically the same behavior as `Int`, but with different messages when - encode/decode fail. -- `Hash` - dictionary, usually describe the type of value stored in Key `Key(Hash)` -- `Float` - Encode/Decode `bytes` <-> `float` -- `SortFloat` - Similar to `Float` with different error messages. -- `ScoreTest` - Argument converter for sorted set score endpoints. -- `StringTest` - Argument converter for sorted set endpoints (lex). -- `ZSet` - Sorted Set. - -#### Implement a test for it - -There are multiple scenarios for test, with different versions of redis server, redis-py, etc. -The tests not only assert the validity of output but runs the same test on a real redis-server and compares the output -to the real server output. - -- Create tests in the relevant test file. -- If support for the command was introduced in a certain version of redis-py ( - see [redis-py release notes](https://github.com/redis/redis-py/releases/tag/v4.3.4)) you can use the - decorator `@testtools.run_test_if_redispy_ver` on your tests. example: - -```python -@testtools.run_test_if_redispy_ver('above', '4.2.0') # This will run for redis-py 4.2.0 or above. -def test_expire_should_not_expire__when_no_expire_is_set(r): - r.set('foo', 'bar') - assert r.get('foo') == b'bar' - assert r.expire('foo', 1, xx=True) == 0 -``` - -#### Updating `REDIS_COMMANDS.md` - -Lastly, run from the root of the project the script to regenerate `REDIS_COMMANDS.md`: - -```bash -python scripts/supported.py > REDIS_COMMANDS.md -``` - -### Write a new test case - -There are multiple scenarios for test, with different versions of python, redis-py and redis server, etc. -The tests not only assert the validity of the expected output with FakeRedis but also with a real redis server. -That way parity of real Redis and FakeRedis is ensured. - -To write a new test case for a command: - -- Determine which mixin the command belongs to and the test file for - the mixin (eg, `string_mixin.py` => `test_string_commands.py`). -- Tests should support python 3.7 and above. -- Determine when support for the command was introduced - - To limit the redis-server versions it will run on use: - `@pytest.mark.max_server(version)` and `@pytest.mark.min_server(version)` - - To limit the redis-py version use `@run_test_if_redispy_ver(above/below, version)` -- pytest will inject a redis connection to the argument `r` of the test. - -Sample of running a test for redis-py v4.2.0 and above, redis-server 7.0 and above. - -```python -@pytest.mark.min_server('7') -@testtools.run_test_if_redispy_ver('above', '4.2.0') -def test_expire_should_not_expire__when_no_expire_is_set(r): - r.set('foo', 'bar') - assert r.get('foo') == b'bar' - assert r.expire('foo', 1, xx=True) == 0 -``` +This module now allows tests to simply use this +module as a reasonable substitute for redis. # Sponsor @@ -354,5 +27,8 @@ You can support this project by becoming a sponsor using [this link](https://github.com/sponsors/cunla). -Alternatively, you can buy me coffee using this -link: [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/danielmoran) +## Security contact information + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. diff -Nru fakeredis-2.4.0/setup.py fakeredis-2.10.3/setup.py --- fakeredis-2.4.0/setup.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/setup.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -from setuptools import setup - -packages = \ -['fakeredis', 'fakeredis.commands_mixins', 'fakeredis.stack'] - -package_data = \ -{'': ['*']} - -install_requires = \ -['redis<4.5', 'sortedcontainers>=2.4.0,<3.0.0'] - -extras_require = \ -{'json': ['jsonpath-ng>=1.5,<2.0'], 'lua': ['lupa>=1.14,<2.0']} - -setup_kwargs = { - 'name': 'fakeredis', - 'version': '2.4.0', - 'description': 'Fake implementation of redis API for testing purposes.', - 'long_description': 'fakeredis: A fake version of a redis-py\n=======================================\n[![badge](https://img.shields.io/pypi/v/fakeredis)](https://pypi.org/project/fakeredis/)\n[![CI](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml/badge.svg)](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml)\n[![badge](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/cunla/b756396efb895f0e34558c980f1ca0c7/raw/fakeredis-py.json)](https://github.com/cunla/fakeredis-py/actions/workflows/test.yml)\n[![badge](https://img.shields.io/pypi/dm/fakeredis)](https://pypi.org/project/fakeredis/)\n[![badge](https://img.shields.io/pypi/l/fakeredis)](./LICENSE)\n[![Open Source Helpers](https://www.codetriage.com/cunla/fakeredis-py/badges/users.svg)](https://www.codetriage.com/cunla/fakeredis-py)\n--------------------\n[Intro](#intro) | [How to Use](#how-to-use) | [Contributing](.github/CONTRIBUTING.md) | [Guides](#guides)\n| [Sponsoring](#sponsor)\n\n# Intro\n\nfakeredis is a pure-Python implementation of the redis-py python client\nthat simulates talking to a redis server. This was created for a single\npurpose: **to write tests**. Setting up redis is not hard, but\nmany times you want to write tests that do not talk to an external server\n(such as redis). This module now allows tests to simply use this\nmodule as a reasonable substitute for redis.\n\nFor a list of supported/unsupported redis commands, see [REDIS_COMMANDS.md](./REDIS_COMMANDS.md).\n\n# Installation\n\nTo install fakeredis-py, simply:\n\n```bash\npip install fakeredis # No additional modules support\n\npip install fakeredis[lua] # Support for LUA scripts\n\npip install fakeredis[json] # Support for RedisJSON commands\n```\n\n# How to Use\n\nFakeRedis can imitate Redis server version 6.x or 7.x.\nIf you do not specify the version, version 7 is used by default.\n\nThe intent is for fakeredis to act as though you\'re talking to a real\nredis server. It does this by storing state internally.\nFor example:\n\n```pycon\n>>> import fakeredis\n>>> r = fakeredis.FakeStrictRedis(version=6)\n>>> r.set(\'foo\', \'bar\')\nTrue\n>>> r.get(\'foo\')\n\'bar\'\n>>> r.lpush(\'bar\', 1)\n1\n>>> r.lpush(\'bar\', 2)\n2\n>>> r.lrange(\'bar\', 0, -1)\n[2, 1]\n```\n\nThe state is stored in an instance of `FakeServer`. If one is not provided at\nconstruction, a new instance is automatically created for you, but you can\nexplicitly create one to share state:\n\n```pycon\n>>> import fakeredis\n>>> server = fakeredis.FakeServer()\n>>> r1 = fakeredis.FakeStrictRedis(server=server)\n>>> r1.set(\'foo\', \'bar\')\nTrue\n>>> r2 = fakeredis.FakeStrictRedis(server=server)\n>>> r2.get(\'foo\')\n\'bar\'\n>>> r2.set(\'bar\', \'baz\')\nTrue\n>>> r1.get(\'bar\')\n\'baz\'\n>>> r2.get(\'bar\')\n\'baz\'\n```\n\nIt is also possible to mock connection errors, so you can effectively test\nyour error handling. Simply set the connected attribute of the server to\n`False` after initialization.\n\n```pycon\n>>> import fakeredis\n>>> server = fakeredis.FakeServer()\n>>> server.connected = False\n>>> r = fakeredis.FakeStrictRedis(server=server)\n>>> r.set(\'foo\', \'bar\')\nConnectionError: FakeRedis is emulating a connection error.\n>>> server.connected = True\n>>> r.set(\'foo\', \'bar\')\nTrue\n```\n\nFakeredis implements the same interface as `redis-py`, the popular\nredis client for python, and models the responses of redis 6.x or 7.x.\n\n## Use to test django-rq\n\nThere is a need to override `django_rq.queues.get_redis_connection` with\na method returning the same connection.\n\n```python\nfrom fakeredis import FakeRedisConnSingleton\n\ndjango_rq.queues.get_redis_connection = FakeRedisConnSingleton()\n```\n\n## Support for additional modules\n\n### RedisJson\n\nCurrently, Redis Json module is partially implemented (\nsee [supported commands](https://github.com/cunla/fakeredis-py/blob/master/REDIS_COMMANDS.md#json)).\n\n```pycon\n>>> import fakeredis\n>>> from redis.commands.json.path import Path\n>>> r = fakeredis.FakeStrictRedis()\n>>> assert r.json().set("foo", Path.root_path(), {"x": "bar"}, ) == 1\n>>> r.json().get("foo")\n{\'x\': \'bar\'}\n>>> r.json().get("foo", Path("x"))\n\'bar\'\n```\n\n### Lua support\n\nIf you wish to have Lua scripting support (this includes features like ``redis.lock.Lock``, which are implemented in\nLua), you will need [lupa](https://pypi.org/project/lupa/), you can simply install it using `pip install fakeredis[lua]`\n\n### JSON support\n\nSupport for JSON commands (eg, [`JSON.GET`](https://redis.io/commands/json.get/)) is implemented using\n[jsonpath-ng](https://github.com/h2non/jsonpath-ng), you can simply install it using `pip install fakeredis[json]`.\n\n## Known Limitations\n\nApart from unimplemented commands, there are a number of cases where fakeredis\nwon\'t give identical results to real redis. The following are differences that\nare unlikely to ever be fixed; there are also differences that are fixable\n(such as commands that do not support all features) which should be filed as\nbugs in GitHub.\n\n- Hyperloglogs are implemented using sets underneath. This means that the\n `type` command will return the wrong answer, you can\'t use `get` to retrieve\n the encoded value, and counts will be slightly different (they will in fact be\n exact).\n- When a command has multiple error conditions, such as operating on a key of\n the wrong type and an integer argument is not well-formed, the choice of\n error to return may not match redis.\n\n- The `incrbyfloat` and `hincrbyfloat` commands in redis use the C `long\n double` type, which typically has more precision than Python\'s `float`\n type.\n\n- Redis makes guarantees about the order in which clients blocked on blocking\n commands are woken up. Fakeredis does not honour these guarantees.\n\n- Where redis contains bugs, fakeredis generally does not try to provide exact\n bug-compatibility. It\'s not practical for fakeredis to try to match the set\n of bugs in your specific version of redis.\n\n- There are a number of cases where the behaviour of redis is undefined, such\n as the order of elements returned by set and hash commands. Fakeredis will\n generally not produce the same results, and in Python versions before 3.6\n may produce different results each time the process is re-run.\n\n- SCAN/ZSCAN/HSCAN/SSCAN will not necessarily iterate all items if items are\n deleted or renamed during iteration. They also won\'t necessarily iterate in\n the same chunk sizes or the same order as redis.\n\n- DUMP/RESTORE will not return or expect data in the RDB format. Instead, the\n `pickle` module is used to mimic an opaque and non-standard format.\n **WARNING**: Do not use RESTORE with untrusted data, as a malicious pickle\n can execute arbitrary code.\n\n--------------------\n\n# Local development environment\n\nTo ensure parity with the real redis, there are a set of integration tests\nthat mirror the unittests. For every unittest that is written, the same\ntest is run against a real redis instance using a real redis-py client\ninstance. In order to run these tests you must have a redis server running\non localhost, port 6379 (the default settings). **WARNING**: the tests will\ncompletely wipe your database!\n\nFirst install poetry if you don\'t have it, and then install all the dependencies:\n\n```bash\npip install poetry\npoetry install\n``` \n\nTo run all the tests:\n\n```bash\npoetry run pytest -v\n```\n\nIf you only want to run tests against fake redis, without a real redis::\n\n```bash\npoetry run pytest -m fake\n```\n\nBecause this module is attempting to provide the same interface as `redis-py`,\nthe python bindings to redis, a reasonable way to test this to take each\nunittest and run it against a real redis server. fakeredis and the real redis\nserver should give the same result. To run tests against a real redis instance\ninstead:\n\n```bash\npoetry run pytest -m real\n```\n\nIf redis is not running, and you try to run tests against a real redis server,\nthese tests will have a result of \'s\' for skipped.\n\nThere are some tests that test redis blocking operations that are somewhat\nslow. If you want to skip these tests during day to day development,\nthey have all been tagged as \'slow\' so you can skip them by running:\n\n```bash\npoetry run pytest -m "not slow"\n```\n\n# Contributing\n\nContributions are welcome.\nYou can contribute in many ways: \nOpen issues for bugs you found, implementing a command which is not yet implemented,\nimplement a test for scenario that is not covered yet, write a guide how to use fakeredis, etc.\n\nPlease see the [contributing guide](.github/CONTRIBUTING.md) for more details.\nIf you\'d like to help out, you can start with any of the issues labeled with `Help wanted`.\n\nThere are guides how to [implement a new command](#implementing-support-for-a-command) and\nhow to [write new test cases](#write-a-new-test-case).\n\nNew contribution guides are welcome.\n\n# Guides\n\n### Implementing support for a command\n\nCreating a new command support should be done in the `FakeSocket` class (in `_fakesocket.py`) by creating the method\nand using `@command` decorator (which should be the command syntax, you can use existing samples on the file).\n\nFor example:\n\n```python\nclass FakeSocket(BaseFakeSocket, FakeLuaSocket):\n # ...\n @command(name=\'zscore\', fixed=(Key(ZSet), bytes), repeat=(), flags=[])\n def zscore(self, key, member):\n try:\n return self._encodefloat(key.value[member], False)\n except KeyError:\n return None\n```\n\n#### How to use `@command` decorator\n\nThe `@command` decorator register the method as a redis command and define the accepted format for it.\nIt will create a `Signature` instance for the command. Whenever the command is triggered, the `Signature.apply(..)`\nmethod will be triggered to check the validity of syntax and analyze the command arguments.\n\nBy default, it takes the name of the method as the command name.\n\nIf the method implements a subcommand (eg, `SCRIPT LOAD`), a Redis module command (eg, `JSON.GET`),\nor a python reserve word where you can not use it as the method name (eg, `EXEC`), then you can supply\nexplicitly the name parameter.\n\nIf the command implemented require certain arguments, they can be supplied in the first parameter as a tuple.\nWhen receiving the command through the socket, the bytes will be converted to the argument types\nsupplied or remain as `bytes`.\n\nArgument types (All in `_commands.py`):\n\n- `Key(KeyType)` - Will get from the DB the key and validate its value is of `KeyType` (if `KeyType` is supplied).\n It will generate a `CommandItem` from it which provides access to the database value.\n- `Int` - Decode the `bytes` to `int` and vice versa.\n- `DbIndex`/`BitOffset`/`BitValue`/`Timeout` - Basically the same behavior as `Int`, but with different messages when\n encode/decode fail.\n- `Hash` - dictionary, usually describe the type of value stored in Key `Key(Hash)`\n- `Float` - Encode/Decode `bytes` <-> `float`\n- `SortFloat` - Similar to `Float` with different error messages.\n- `ScoreTest` - Argument converter for sorted set score endpoints.\n- `StringTest` - Argument converter for sorted set endpoints (lex).\n- `ZSet` - Sorted Set.\n\n#### Implement a test for it\n\nThere are multiple scenarios for test, with different versions of redis server, redis-py, etc.\nThe tests not only assert the validity of output but runs the same test on a real redis-server and compares the output\nto the real server output.\n\n- Create tests in the relevant test file.\n- If support for the command was introduced in a certain version of redis-py (\n see [redis-py release notes](https://github.com/redis/redis-py/releases/tag/v4.3.4)) you can use the\n decorator `@testtools.run_test_if_redispy_ver` on your tests. example:\n\n```python\n@testtools.run_test_if_redispy_ver(\'above\', \'4.2.0\') # This will run for redis-py 4.2.0 or above.\ndef test_expire_should_not_expire__when_no_expire_is_set(r):\n r.set(\'foo\', \'bar\')\n assert r.get(\'foo\') == b\'bar\'\n assert r.expire(\'foo\', 1, xx=True) == 0\n```\n\n#### Updating `REDIS_COMMANDS.md`\n\nLastly, run from the root of the project the script to regenerate `REDIS_COMMANDS.md`:\n\n```bash\npython scripts/supported.py > REDIS_COMMANDS.md \n```\n\n### Write a new test case\n\nThere are multiple scenarios for test, with different versions of python, redis-py and redis server, etc.\nThe tests not only assert the validity of the expected output with FakeRedis but also with a real redis server.\nThat way parity of real Redis and FakeRedis is ensured.\n\nTo write a new test case for a command:\n\n- Determine which mixin the command belongs to and the test file for\n the mixin (eg, `string_mixin.py` => `test_string_commands.py`).\n- Tests should support python 3.7 and above.\n- Determine when support for the command was introduced\n - To limit the redis-server versions it will run on use:\n `@pytest.mark.max_server(version)` and `@pytest.mark.min_server(version)`\n - To limit the redis-py version use `@run_test_if_redispy_ver(above/below, version)`\n- pytest will inject a redis connection to the argument `r` of the test.\n\nSample of running a test for redis-py v4.2.0 and above, redis-server 7.0 and above.\n\n```python\n@pytest.mark.min_server(\'7\')\n@testtools.run_test_if_redispy_ver(\'above\', \'4.2.0\')\ndef test_expire_should_not_expire__when_no_expire_is_set(r):\n r.set(\'foo\', \'bar\')\n assert r.get(\'foo\') == b\'bar\'\n assert r.expire(\'foo\', 1, xx=True) == 0\n```\n\n# Sponsor\n\nfakeredis-py is developed for free.\n\nYou can support this project by becoming a sponsor using [this link](https://github.com/sponsors/cunla).\n\nAlternatively, you can buy me coffee using this\nlink: [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/danielmoran)\n', - 'author': 'James Saryerwinnie', - 'author_email': 'js@jamesls.com', - 'maintainer': 'Daniel Moran', - 'maintainer_email': 'daniel.maruani@gmail.com', - 'url': 'https://github.com/cunla/fakeredis-py', - 'packages': packages, - 'package_data': package_data, - 'install_requires': install_requires, - 'extras_require': extras_require, - 'python_requires': '>=3.8.1,<4.0', -} - - -setup(**setup_kwargs) diff -Nru fakeredis-2.4.0/test/conftest.py fakeredis-2.10.3/test/conftest.py --- fakeredis-2.4.0/test/conftest.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/conftest.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,82 @@ +import pytest +import pytest_asyncio +import redis +from packaging.version import Version + +import fakeredis + + +@pytest_asyncio.fixture(scope="session") +def is_redis_running(): + client = None + try: + client = redis.StrictRedis('localhost', port=6379) + client.ping() + return True + except redis.ConnectionError: + return False + finally: + if hasattr(client, 'close'): + client.close() # Absent in older versions of redis-py + + +@pytest_asyncio.fixture(name='fake_server') +def _fake_server(request): + min_server_marker = request.node.get_closest_marker('min_server') + server_version = 6 + if min_server_marker and min_server_marker.args[0].startswith('7'): + server_version = 7 + server = fakeredis.FakeServer(version=server_version) + server.connected = request.node.get_closest_marker('disconnected') is None + return server + + +@pytest_asyncio.fixture +def r(request, create_redis): + rconn = create_redis(db=0) + connected = request.node.get_closest_marker('disconnected') is None + if connected: + rconn.flushall() + yield rconn + if connected: + rconn.flushall() + if hasattr(r, 'close'): + rconn.close() # Older versions of redis-py don't have this method + + +@pytest_asyncio.fixture( + name='create_redis', + params=[ + pytest.param('StrictRedis', marks=pytest.mark.real), + pytest.param('FakeStrictRedis', marks=pytest.mark.fake), + ] +) +def _create_redis(request): + name = request.param + if not name.startswith('Fake') and not request.getfixturevalue('is_redis_running'): + pytest.skip('Redis is not running') + decode_responses = request.node.get_closest_marker('decode_responses') is not None + + def marker_version_value(marker_name): + marker_value = request.node.get_closest_marker(marker_name) + return (None, None) if marker_value is None else (marker_value, Version(marker_value.args[0])) + + def factory(db=0): + if name.startswith('Fake'): + fake_server = request.getfixturevalue('fake_server') + cls = getattr(fakeredis, name) + return cls(db=db, decode_responses=decode_responses, server=fake_server) + else: + cls = getattr(redis, name) + conn = cls('localhost', port=6379, db=db, decode_responses=decode_responses) + server_version = conn.info()['redis_version'] + + min_version, min_server_marker = marker_version_value('min_server') + if min_server_marker is not None and Version(server_version) < min_server_marker: + pytest.skip(f'Redis server {min_version} or more required but {server_version} found') + max_version, max_server_marker = marker_version_value('max_server') + if max_server_marker is not None and Version(server_version) > max_server_marker: + pytest.skip(f'Redis server {max_version} or less required but {server_version} found') + return conn + + return factory diff -Nru fakeredis-2.4.0/test/test_connection.py fakeredis-2.10.3/test/test_connection.py --- fakeredis-2.4.0/test/test_connection.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_connection.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,482 @@ +import pytest +import redis +import redis.client +from redis.exceptions import ResponseError + +from test import testtools + + +def test_ping(r): + assert r.ping() + assert testtools.raw_command(r, 'ping', 'test') == b'test' + + +def test_echo(r): + assert r.echo(b'hello') == b'hello' + assert r.echo('hello') == b'hello' + + +@testtools.fake_only +def test_time(r, mocker): + fake_time = mocker.patch('time.time') + fake_time.return_value = 1234567890.1234567 + assert r.time() == (1234567890, 123457) + fake_time.return_value = 1234567890.000001 + assert r.time() == (1234567890, 1) + fake_time.return_value = 1234567890.9999999 + assert r.time() == (1234567891, 0) + + +@pytest.mark.decode_responses +class TestDecodeResponses: + def test_decode_str(self, r): + r.set('foo', 'bar') + assert r.get('foo') == 'bar' + + def test_decode_set(self, r): + r.sadd('foo', 'member1') + assert r.smembers('foo') == {'member1'} + + def test_decode_list(self, r): + r.rpush('foo', 'a', 'b') + assert r.lrange('foo', 0, -1) == ['a', 'b'] + + def test_decode_dict(self, r): + r.hset('foo', 'key', 'value') + assert r.hgetall('foo') == {'key': 'value'} + + def test_decode_error(self, r): + r.set('foo', 'bar') + with pytest.raises(ResponseError) as exc_info: + r.hset('foo', 'bar', 'baz') + assert isinstance(exc_info.value.args[0], str) + + +@pytest.mark.disconnected +@testtools.fake_only +class TestFakeStrictRedisConnectionErrors: + def test_flushdb(self, r): + with pytest.raises(redis.ConnectionError): + r.flushdb() + + def test_flushall(self, r): + with pytest.raises(redis.ConnectionError): + r.flushall() + + def test_append(self, r): + with pytest.raises(redis.ConnectionError): + r.append('key', 'value') + + def test_bitcount(self, r): + with pytest.raises(redis.ConnectionError): + r.bitcount('key', 0, 20) + + def test_decr(self, r): + with pytest.raises(redis.ConnectionError): + r.decr('key', 2) + + def test_exists(self, r): + with pytest.raises(redis.ConnectionError): + r.exists('key') + + def test_expire(self, r): + with pytest.raises(redis.ConnectionError): + r.expire('key', 20) + + def test_pexpire(self, r): + with pytest.raises(redis.ConnectionError): + r.pexpire('key', 20) + + def test_echo(self, r): + with pytest.raises(redis.ConnectionError): + r.echo('value') + + def test_get(self, r): + with pytest.raises(redis.ConnectionError): + r.get('key') + + def test_getbit(self, r): + with pytest.raises(redis.ConnectionError): + r.getbit('key', 2) + + def test_getset(self, r): + with pytest.raises(redis.ConnectionError): + r.getset('key', 'value') + + def test_incr(self, r): + with pytest.raises(redis.ConnectionError): + r.incr('key') + + def test_incrby(self, r): + with pytest.raises(redis.ConnectionError): + r.incrby('key') + + def test_ncrbyfloat(self, r): + with pytest.raises(redis.ConnectionError): + r.incrbyfloat('key') + + def test_keys(self, r): + with pytest.raises(redis.ConnectionError): + r.keys() + + def test_mget(self, r): + with pytest.raises(redis.ConnectionError): + r.mget(['key1', 'key2']) + + def test_mset(self, r): + with pytest.raises(redis.ConnectionError): + r.mset({'key': 'value'}) + + def test_msetnx(self, r): + with pytest.raises(redis.ConnectionError): + r.msetnx({'key': 'value'}) + + def test_persist(self, r): + with pytest.raises(redis.ConnectionError): + r.persist('key') + + def test_rename(self, r): + server = r.connection_pool.connection_kwargs['server'] + server.connected = True + r.set('key1', 'value') + server.connected = False + with pytest.raises(redis.ConnectionError): + r.rename('key1', 'key2') + server.connected = True + assert r.exists('key1') + + def test_eval(self, r): + with pytest.raises(redis.ConnectionError): + r.eval('', 0) + + def test_lpush(self, r): + with pytest.raises(redis.ConnectionError): + r.lpush('name', 1, 2) + + def test_lrange(self, r): + with pytest.raises(redis.ConnectionError): + r.lrange('name', 1, 5) + + def test_llen(self, r): + with pytest.raises(redis.ConnectionError): + r.llen('name') + + def test_lrem(self, r): + with pytest.raises(redis.ConnectionError): + r.lrem('name', 2, 2) + + def test_rpush(self, r): + with pytest.raises(redis.ConnectionError): + r.rpush('name', 1) + + def test_lpop(self, r): + with pytest.raises(redis.ConnectionError): + r.lpop('name') + + def test_lset(self, r): + with pytest.raises(redis.ConnectionError): + r.lset('name', 1, 4) + + def test_rpushx(self, r): + with pytest.raises(redis.ConnectionError): + r.rpushx('name', 1) + + def test_ltrim(self, r): + with pytest.raises(redis.ConnectionError): + r.ltrim('name', 1, 4) + + def test_lindex(self, r): + with pytest.raises(redis.ConnectionError): + r.lindex('name', 1) + + def test_lpushx(self, r): + with pytest.raises(redis.ConnectionError): + r.lpushx('name', 1) + + def test_rpop(self, r): + with pytest.raises(redis.ConnectionError): + r.rpop('name') + + def test_linsert(self, r): + with pytest.raises(redis.ConnectionError): + r.linsert('name', 'where', 'refvalue', 'value') + + def test_rpoplpush(self, r): + with pytest.raises(redis.ConnectionError): + r.rpoplpush('src', 'dst') + + def test_blpop(self, r): + with pytest.raises(redis.ConnectionError): + r.blpop('keys') + + def test_brpop(self, r): + with pytest.raises(redis.ConnectionError): + r.brpop('keys') + + def test_brpoplpush(self, r): + with pytest.raises(redis.ConnectionError): + r.brpoplpush('src', 'dst') + + def test_hdel(self, r): + with pytest.raises(redis.ConnectionError): + r.hdel('name') + + def test_hexists(self, r): + with pytest.raises(redis.ConnectionError): + r.hexists('name', 'key') + + def test_hget(self, r): + with pytest.raises(redis.ConnectionError): + r.hget('name', 'key') + + def test_hgetall(self, r): + with pytest.raises(redis.ConnectionError): + r.hgetall('name') + + def test_hincrby(self, r): + with pytest.raises(redis.ConnectionError): + r.hincrby('name', 'key') + + def test_hincrbyfloat(self, r): + with pytest.raises(redis.ConnectionError): + r.hincrbyfloat('name', 'key') + + def test_hkeys(self, r): + with pytest.raises(redis.ConnectionError): + r.hkeys('name') + + def test_hlen(self, r): + with pytest.raises(redis.ConnectionError): + r.hlen('name') + + def test_hset(self, r): + with pytest.raises(redis.ConnectionError): + r.hset('name', 'key', 1) + + def test_hsetnx(self, r): + with pytest.raises(redis.ConnectionError): + r.hsetnx('name', 'key', 2) + + def test_hmset(self, r): + with pytest.raises(redis.ConnectionError): + r.hmset('name', {'key': 1}) + + def test_hmget(self, r): + with pytest.raises(redis.ConnectionError): + r.hmget('name', ['a', 'b']) + + def test_hvals(self, r): + with pytest.raises(redis.ConnectionError): + r.hvals('name') + + def test_sadd(self, r): + with pytest.raises(redis.ConnectionError): + r.sadd('name', 1, 2) + + def test_scard(self, r): + with pytest.raises(redis.ConnectionError): + r.scard('name') + + def test_sdiff(self, r): + with pytest.raises(redis.ConnectionError): + r.sdiff(['a', 'b']) + + def test_sdiffstore(self, r): + with pytest.raises(redis.ConnectionError): + r.sdiffstore('dest', ['a', 'b']) + + def test_sinter(self, r): + with pytest.raises(redis.ConnectionError): + r.sinter(['a', 'b']) + + def test_sinterstore(self, r): + with pytest.raises(redis.ConnectionError): + r.sinterstore('dest', ['a', 'b']) + + def test_sismember(self, r): + with pytest.raises(redis.ConnectionError): + r.sismember('name', 20) + + def test_smembers(self, r): + with pytest.raises(redis.ConnectionError): + r.smembers('name') + + def test_smove(self, r): + with pytest.raises(redis.ConnectionError): + r.smove('src', 'dest', 20) + + def test_spop(self, r): + with pytest.raises(redis.ConnectionError): + r.spop('name') + + def test_srandmember(self, r): + with pytest.raises(redis.ConnectionError): + r.srandmember('name') + + def test_srem(self, r): + with pytest.raises(redis.ConnectionError): + r.srem('name') + + def test_sunion(self, r): + with pytest.raises(redis.ConnectionError): + r.sunion(['a', 'b']) + + def test_sunionstore(self, r): + with pytest.raises(redis.ConnectionError): + r.sunionstore('dest', ['a', 'b']) + + def test_zadd(self, r): + with pytest.raises(redis.ConnectionError): + r.zadd('name', {'key': 'value'}) + + def test_zcard(self, r): + with pytest.raises(redis.ConnectionError): + r.zcard('name') + + def test_zcount(self, r): + with pytest.raises(redis.ConnectionError): + r.zcount('name', 1, 5) + + def test_zincrby(self, r): + with pytest.raises(redis.ConnectionError): + r.zincrby('name', 1, 1) + + def test_zinterstore(self, r): + with pytest.raises(redis.ConnectionError): + r.zinterstore('dest', ['a', 'b']) + + def test_zrange(self, r): + with pytest.raises(redis.ConnectionError): + r.zrange('name', 1, 5) + + def test_zrangebyscore(self, r): + with pytest.raises(redis.ConnectionError): + r.zrangebyscore('name', 1, 5) + + def test_rangebylex(self, r): + with pytest.raises(redis.ConnectionError): + r.zrangebylex('name', 1, 4) + + def test_zrem(self, r): + with pytest.raises(redis.ConnectionError): + r.zrem('name', 'value') + + def test_zremrangebyrank(self, r): + with pytest.raises(redis.ConnectionError): + r.zremrangebyrank('name', 1, 5) + + def test_zremrangebyscore(self, r): + with pytest.raises(redis.ConnectionError): + r.zremrangebyscore('name', 1, 5) + + def test_zremrangebylex(self, r): + with pytest.raises(redis.ConnectionError): + r.zremrangebylex('name', 1, 5) + + def test_zlexcount(self, r): + with pytest.raises(redis.ConnectionError): + r.zlexcount('name', 1, 5) + + def test_zrevrange(self, r): + with pytest.raises(redis.ConnectionError): + r.zrevrange('name', 1, 5, 1) + + def test_zrevrangebyscore(self, r): + with pytest.raises(redis.ConnectionError): + r.zrevrangebyscore('name', 5, 1) + + def test_zrevrangebylex(self, r): + with pytest.raises(redis.ConnectionError): + r.zrevrangebylex('name', 5, 1) + + def test_zrevran(self, r): + with pytest.raises(redis.ConnectionError): + r.zrevrank('name', 2) + + def test_zscore(self, r): + with pytest.raises(redis.ConnectionError): + r.zscore('name', 2) + + def test_zunionstor(self, r): + with pytest.raises(redis.ConnectionError): + r.zunionstore('dest', ['1', '2']) + + def test_pipeline(self, r): + with pytest.raises(redis.ConnectionError): + r.pipeline().watch('key') + + def test_transaction(self, r): + with pytest.raises(redis.ConnectionError): + def func(a): + return a * a + + r.transaction(func, 3) + + def test_lock(self, r): + with pytest.raises(redis.ConnectionError): + with r.lock('name'): + pass + + def test_pubsub(self, r): + with pytest.raises(redis.ConnectionError): + r.pubsub().subscribe('channel') + + def test_pfadd(self, r): + with pytest.raises(redis.ConnectionError): + r.pfadd('name', 1) + + def test_pfmerge(self, r): + with pytest.raises(redis.ConnectionError): + r.pfmerge('dest', 'a', 'b') + + def test_scan(self, r): + with pytest.raises(redis.ConnectionError): + list(r.scan()) + + def test_sscan(self, r): + with pytest.raises(redis.ConnectionError): + r.sscan('name') + + def test_hscan(self, r): + with pytest.raises(redis.ConnectionError): + r.hscan('name') + + def test_scan_iter(self, r): + with pytest.raises(redis.ConnectionError): + list(r.scan_iter()) + + def test_sscan_iter(self, r): + with pytest.raises(redis.ConnectionError): + list(r.sscan_iter('name')) + + def test_hscan_iter(self, r): + with pytest.raises(redis.ConnectionError): + list(r.hscan_iter('name')) + + +@pytest.mark.disconnected +@testtools.fake_only +class TestPubSubConnected: + @pytest.fixture + def pubsub(self, r): + return r.pubsub() + + def test_basic_subscribe(self, pubsub): + with pytest.raises(redis.ConnectionError): + pubsub.subscribe('logs') + + def test_subscription_conn_lost(self, fake_server, pubsub): + fake_server.connected = True + pubsub.subscribe('logs') + fake_server.connected = False + # The initial message is already in the pipe + msg = pubsub.get_message() + check = { + 'type': 'subscribe', + 'pattern': None, + 'channel': b'logs', + 'data': 1 + } + assert msg == check, 'Message was not published to channel' + with pytest.raises(redis.ConnectionError): + pubsub.get_message() diff -Nru fakeredis-2.4.0/test/test_extract_args.py fakeredis-2.10.3/test/test_extract_args.py --- fakeredis-2.4.0/test/test_extract_args.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_extract_args.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,103 @@ +import pytest + +from fakeredis._command_args_parsing import extract_args +from fakeredis._helpers import SimpleError + + +def test_extract_args(): + args = (b'nx', b'ex', b'324', b'xx',) + (xx, nx, ex, keepttl), _ = extract_args(args, ('nx', 'xx', '+ex', 'keepttl')) + assert xx + assert nx + assert ex == 324 + assert not keepttl + + +def test_extract_args__should_raise_error(): + args = (b'nx', b'ex', b'324', b'xx', b'something') + with pytest.raises(SimpleError): + (xx, nx, ex, keepttl), _ = extract_args(args, ('nx', 'xx', '+ex', 'keepttl')) + + +def test_extract_args__should_return_something(): + args = (b'nx', b'ex', b'324', b'xx', b'something') + + (xx, nx, ex, keepttl), left = extract_args( + args, ('nx', 'xx', '+ex', 'keepttl'), error_on_unexpected=False) + assert xx + assert nx + assert ex == 324 + assert not keepttl + assert left == (b'something',) + + args = (b'nx', b'something', b'ex', b'324', b'xx',) + + (xx, nx, ex, keepttl), left = extract_args( + args, ('nx', 'xx', '+ex', 'keepttl'), + error_on_unexpected=False, + left_from_first_unexpected=False + ) + assert xx + assert nx + assert ex == 324 + assert not keepttl + assert left == [b'something', ] + + +def test_extract_args__multiple_numbers(): + args = (b'nx', b'limit', b'324', b'123', b'xx',) + + (xx, nx, limit, keepttl), _ = extract_args( + args, ('nx', 'xx', '++limit', 'keepttl')) + assert xx + assert nx + assert limit == [324, 123] + assert not keepttl + + (xx, nx, limit, keepttl), _ = extract_args( + (b'nx', b'xx',), + ('nx', 'xx', '++limit', 'keepttl')) + assert xx + assert nx + assert not keepttl + assert limit == [None, None] + + +def test_extract_args__extract_non_numbers(): + args = (b'by', b'dd', b'nx', b'limit', b'324', b'123', b'xx',) + + (xx, nx, limit, sortby), _ = extract_args( + args, ('nx', 'xx', '++limit', '*by')) + assert xx + assert nx + assert limit == [324, 123] + assert sortby == b'dd' + + +def test_extract_args__extract_maxlen(): + args = (b'MAXLEN', b'5') + (nomkstream, limit, maxlen, maxid), left_args = extract_args( + args, ('nomkstream', '+limit', '~+maxlen', '~maxid'), error_on_unexpected=False) + assert not nomkstream + assert limit is None + assert maxlen == 5 + assert maxid is None + + args = (b'MAXLEN', b'~', b'5', b'maxid', b'~', b'1') + (nomkstream, limit, maxlen, maxid), left_args = extract_args( + args, ('nomkstream', '+limit', '~+maxlen', '~maxid'), error_on_unexpected=False) + assert not nomkstream + assert limit is None + assert maxlen == 5 + assert maxid == b"1" + + args = (b'by', b'dd', b'nx', b'maxlen', b'~', b'10', + b'limit', b'324', b'123', b'xx',) + + (nx, maxlen, xx, limit, sortby), _ = extract_args( + args, ('nx', '~+maxlen', 'xx', '++limit', '*by')) + assert xx + assert nx + assert maxlen == 10 + assert limit == [324, 123] + assert sortby == b'dd' diff -Nru fakeredis-2.4.0/test/test_general.py fakeredis-2.10.3/test/test_general.py --- fakeredis-2.4.0/test/test_general.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_general.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,24 @@ +import pytest +import redis + +import fakeredis +from test.testtools import raw_command + + +def test_singleton(): + conn_generator = fakeredis.FakeRedisConnSingleton() + conn1 = conn_generator(dict(), False) + conn2 = conn_generator(dict(), False) + assert conn1.set('foo', 'bar') is True + assert conn2.get('foo') == b'bar' + + +def test_asyncioio_is_used(): + """Redis 4.2+ has support for asyncio and should be preferred over aioredis""" + from fakeredis import aioredis + assert not hasattr(aioredis, "__version__") + + +def test_unknown_command(r): + with pytest.raises(redis.ResponseError): + raw_command(r, '0 3 3') diff -Nru fakeredis-2.4.0/test/test_hypothesis.py fakeredis-2.10.3/test/test_hypothesis.py --- fakeredis-2.4.0/test/test_hypothesis.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_hypothesis.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,637 @@ +import functools +import hypothesis +import hypothesis.stateful +import hypothesis.strategies as st +import operator +import pytest +import redis +import sys +from hypothesis.stateful import rule, initialize, precondition + +import fakeredis + +self_strategy = st.runner() + + +def get_redis_version(): + try: + r = redis.StrictRedis('localhost', port=6379) + r.ping() + return int(r.info()['redis_version'][0]) + except redis.ConnectionError: + return 6 + finally: + if hasattr(r, 'close'): + r.close() # Absent in older versions of redis-py + + +@st.composite +def sample_attr(draw, name): + """Strategy for sampling a specific attribute from a state machine""" + machine = draw(self_strategy) + values = getattr(machine, name) + position = draw(st.integers(min_value=0, max_value=len(values) - 1)) + return values[position] + + +redis_ver = get_redis_version() + +keys = sample_attr('keys') +fields = sample_attr('fields') +values = sample_attr('values') +scores = sample_attr('scores') + +int_as_bytes = st.builds(lambda x: str(default_normalize(x)).encode(), st.integers()) +float_as_bytes = st.builds(lambda x: repr(default_normalize(x)).encode(), st.floats(width=32)) +counts = st.integers(min_value=-3, max_value=3) | st.integers() +limits = st.just(()) | st.tuples(st.just('limit'), counts, counts) +# Redis has an integer overflow bug in swapdb, so we confine the numbers to +# a limited range (https://github.com/antirez/redis/issues/5737). +dbnums = st.integers(min_value=0, max_value=3) | st.integers(min_value=-1000, max_value=1000) +# The filter is to work around https://github.com/antirez/redis/issues/5632 +patterns = (st.text(alphabet=st.sampled_from('[]^$*.?-azAZ\\\r\n\t')) + | st.binary().filter(lambda x: b'\0' not in x)) +score_tests = scores | st.builds(lambda x: b'(' + repr(x).encode(), scores) +string_tests = ( + st.sampled_from([b'+', b'-']) + | st.builds(operator.add, st.sampled_from([b'(', b'[']), fields)) +# Redis has integer overflow bugs in time computations, which is why we set a maximum. +expires_seconds = st.integers(min_value=100000, max_value=10000000000) +expires_ms = st.integers(min_value=100000000, max_value=10000000000000) + + +class WrappedException: + """Wraps an exception for the purposes of comparison.""" + + def __init__(self, exc): + self.wrapped = exc + + def __str__(self): + return str(self.wrapped) + + def __repr__(self): + return 'WrappedException({!r})'.format(self.wrapped) + + def __eq__(self, other): + if not isinstance(other, WrappedException): + return NotImplemented + if type(self.wrapped) != type(other.wrapped): # noqa: E721 + return False + # TODO: re-enable after more carefully handling order of error checks + # return self.wrapped.args == other.wrapped.args + return True + + def __ne__(self, other): + if not isinstance(other, WrappedException): + return NotImplemented + return not self == other + + +def wrap_exceptions(obj): + if isinstance(obj, list): + return [wrap_exceptions(item) for item in obj] + elif isinstance(obj, Exception): + return WrappedException(obj) + else: + return obj + + +def sort_list(lst): + if isinstance(lst, list): + return sorted(lst) + else: + return lst + + +def flatten(args): + if isinstance(args, (list, tuple)): + for arg in args: + yield from flatten(arg) + elif args is not None: + yield args + + +def default_normalize(x): + if redis_ver >= 7 and (isinstance(x, float) or isinstance(x, int)): + return 0 + x + + return x + + +class Command: + def __init__(self, *args): + args = list(flatten(args)) + args = [default_normalize(x) for x in args] + self.args = tuple(args) + + def __repr__(self): + parts = [repr(arg) for arg in self.args] + return 'Command({})'.format(', '.join(parts)) + + @staticmethod + def encode(arg): + encoder = redis.connection.Encoder('utf-8', 'replace', False) + return encoder.encode(arg) + + @property + def normalize(self): + command = self.encode(self.args[0]).lower() if self.args else None + # Functions that return a list in arbitrary order + unordered = { + b'keys', + b'sort', + b'hgetall', b'hkeys', b'hvals', + b'sdiff', b'sinter', b'sunion', + b'smembers' + } + if command in unordered: + return sort_list + else: + return lambda x: x + + @property + def testable(self): + """Whether this command is suitable for a test. + + The fuzzer can create commands with behaviour that is + non-deterministic, not supported, or which hits redis bugs. + """ + N = len(self.args) + if N == 0: + return False + command = self.encode(self.args[0]).lower() + if not command.split(): + return False + if command == b'keys' and N == 2 and self.args[1] != b'*': + return False + # redis will ignore a NUL character in some commands but not others + # e.g. it recognises EXEC\0 but not MULTI\00. Rather than try to + # reproduce this quirky behaviour, just skip these tests. + if b'\0' in command: + return False + return True + + +def commands(*args, **kwargs): + return st.builds(functools.partial(Command, **kwargs), *args) + + +# # TODO: all expiry-related commands +common_commands = ( + commands(st.sampled_from(['del', 'persist', 'type', 'unlink']), keys) + | commands(st.just('exists'), st.lists(keys)) + | commands(st.just('keys'), st.just('*')) + # Disabled for now due to redis giving wrong answers + # (https://github.com/antirez/redis/issues/5632) + # | commands(st.just('keys'), patterns) + | commands(st.just('move'), keys, dbnums) + | commands(st.sampled_from(['rename', 'renamenx']), keys, keys) + # TODO: find a better solution to sort instability than throwing + # away the sort entirely with normalize. This also prevents us + # using LIMIT. + | commands(st.just('sort'), keys, + st.none() | st.just('asc'), + st.none() | st.just('desc'), + st.none() | st.just('alpha')) +) + + +def build_zstore(command, dest, sources, weights, aggregate): + args = [command, dest, len(sources)] + args += [source[0] for source in sources] + if weights: + args.append('weights') + args += [source[1] for source in sources] + if aggregate: + args += ['aggregate', aggregate] + return Command(args) + + +zset_no_score_create_commands = ( + commands(st.just('zadd'), keys, st.lists(st.tuples(st.just(0), fields), min_size=1)) +) +zset_no_score_commands = ( # TODO: test incr + commands(st.just('zadd'), keys, + st.none() | st.just('nx'), + st.none() | st.just('xx'), + st.none() | st.just('ch'), + st.none() | st.just('incr'), + st.lists(st.tuples(st.just(0), fields))) + | commands(st.just('zlexcount'), keys, string_tests, string_tests) + | commands(st.sampled_from(['zrangebylex', 'zrevrangebylex']), + keys, string_tests, string_tests, + limits) + | commands(st.just('zremrangebylex'), keys, string_tests, string_tests) +) + +bad_commands = ( + # redis-py splits the command on spaces, and hangs if that ends up + # being an empty list + commands(st.text().filter(lambda x: bool(x.split())), + st.lists(st.binary() | st.text())) +) + +attrs = st.fixed_dictionaries({ + 'keys': st.lists(st.binary(), min_size=2, max_size=5, unique=True), + 'fields': st.lists(st.binary(), min_size=2, max_size=5, unique=True), + 'values': st.lists(st.binary() | int_as_bytes | float_as_bytes, + min_size=2, max_size=5, unique=True), + 'scores': st.lists(st.floats(width=32), min_size=2, max_size=5, unique=True) +}) + + +@hypothesis.settings(max_examples=1000) +class CommonMachine(hypothesis.stateful.RuleBasedStateMachine): + create_command_strategy = st.nothing() + + def __init__(self): + super().__init__() + try: + self.real = redis.StrictRedis('localhost', port=6379) + self.real.ping() + except redis.ConnectionError: + pytest.skip('redis is not running') + if self.real.info('server').get('arch_bits') != 64: + self.real.connection_pool.disconnect() + pytest.skip('redis server is not 64-bit') + self.fake = fakeredis.FakeStrictRedis(version=redis_ver) + # Disable the response parsing so that we can check the raw values returned + self.fake.response_callbacks.clear() + self.real.response_callbacks.clear() + self.transaction_normalize = [] + self.keys = [] + self.fields = [] + self.values = [] + self.scores = [] + self.initialized_data = False + try: + self.real.execute_command('discard') + except redis.ResponseError: + pass + self.real.flushall() + + def teardown(self): + self.real.connection_pool.disconnect() + self.fake.connection_pool.disconnect() + super().teardown() + + @staticmethod + def _evaluate(client, command): + try: + result = client.execute_command(*command.args) + if result != 'QUEUED': + result = command.normalize(result) + exc = None + except Exception as e: + result = exc = e + return wrap_exceptions(result), exc + + def _compare(self, command): + fake_result, fake_exc = self._evaluate(self.fake, command) + real_result, real_exc = self._evaluate(self.real, command) + + if fake_exc is not None and real_exc is None: + print('{} raised on only on fake when running {}'.format(fake_exc, command), file=sys.stderr) + raise fake_exc + elif real_exc is not None and fake_exc is None: + assert real_exc == fake_exc, "Expected exception {} not raised".format(real_exc) + elif (real_exc is None and isinstance(real_result, list) + and command.args and command.args[0].lower() == 'exec'): + assert fake_result is not None + # Transactions need to use the normalize functions of the + # component commands. + assert len(self.transaction_normalize) == len(real_result) + assert len(self.transaction_normalize) == len(fake_result) + for n, r, f in zip(self.transaction_normalize, real_result, fake_result): + assert n(f) == n(r) + self.transaction_normalize = [] + else: + if fake_result != real_result: + print('{}!={} when running {}'.format(fake_result, real_result, command), + file=sys.stderr) + assert fake_result == real_result, "Discrepancy when running command {}".format(command) + if real_result == b'QUEUED': + # Since redis removes the distinction between simple strings and + # bulk strings, this might not actually indicate that we're in a + # transaction. But it is extremely unlikely that hypothesis will + # find such examples. + self.transaction_normalize.append(command.normalize) + if (len(command.args) == 1 + and Command.encode(command.args[0]).lower() in (b'discard', b'exec')): + self.transaction_normalize = [] + + @initialize(attrs=attrs) + def init_attrs(self, attrs): + for key, value in attrs.items(): + setattr(self, key, value) + + # hypothesis doesn't allow ordering of @initialize, so we have to put + # preconditions on rules to ensure we call init_data exactly once and + # after init_attrs. + @precondition(lambda self: not self.initialized_data) + @rule(commands=self_strategy.flatmap( + lambda self: st.lists(self.create_command_strategy))) + def init_data(self, commands): + for command in commands: + self._compare(command) + self.initialized_data = True + + @precondition(lambda self: self.initialized_data) + @rule(command=self_strategy.flatmap(lambda self: self.command_strategy)) + def one_command(self, command): + self._compare(command) + + +class BaseTest: + """Base class for test classes.""" + + create_command_strategy = st.nothing() + + @pytest.mark.slow + def test(self): + class Machine(CommonMachine): + create_command_strategy = self.create_command_strategy + command_strategy = self.command_strategy + + # hypothesis.settings.register_profile("debug", max_examples=10, verbosity=hypothesis.Verbosity.debug) + # hypothesis.settings.load_profile("debug") + hypothesis.stateful.run_state_machine_as_test(Machine) + + +class TestConnection(BaseTest): + # TODO: tests for select + connection_commands = ( + commands(st.just('echo'), values) + | commands(st.just('ping'), st.lists(values, max_size=2)) + | commands(st.just('swapdb'), dbnums, dbnums) + ) + command_strategy = connection_commands | common_commands + + +class TestString(BaseTest): + string_commands = ( + commands(st.just('append'), keys, values) + | commands(st.just('bitcount'), keys) + | commands(st.just('bitcount'), keys, values, values) + | commands(st.sampled_from(['incr', 'decr']), keys) + | commands(st.sampled_from(['incrby', 'decrby']), keys, values) + # Disabled for now because Python can't exactly model the long doubles. + # TODO: make a more targeted test that checks the basics. + # TODO: check how it gets stringified, without relying on hypothesis + # to get generate a get call before it gets overwritten. + # | commands(st.just('incrbyfloat'), keys, st.floats(width=32)) + | commands(st.just('get'), keys) + | commands(st.just('getbit'), keys, counts) + | commands(st.just('setbit'), keys, counts, + st.integers(min_value=0, max_value=1) | st.integers()) + | commands(st.sampled_from(['substr', 'getrange']), keys, counts, counts) + | commands(st.just('getset'), keys, values) + | commands(st.just('mget'), st.lists(keys)) + | commands(st.sampled_from(['mset', 'msetnx']), st.lists(st.tuples(keys, values))) + | commands(st.just('set'), keys, values, + st.none() | st.just('nx'), + st.none() | st.just('xx'), + st.none() | st.just('keepttl')) + | commands(st.just('setex'), keys, expires_seconds, values) + | commands(st.just('psetex'), keys, expires_ms, values) + | commands(st.just('setnx'), keys, values) + | commands(st.just('setrange'), keys, counts, values) + | commands(st.just('strlen'), keys) + ) + create_command_strategy = commands(st.just('set'), keys, values) + command_strategy = string_commands | common_commands + + +class TestHash(BaseTest): + # TODO: add a test for hincrbyfloat. See incrbyfloat for why this is + # problematic. + hash_commands = ( + commands(st.just('hmset'), keys, st.lists(st.tuples(fields, values))) + | commands(st.just('hdel'), keys, st.lists(fields)) + | commands(st.just('hexists'), keys, fields) + | commands(st.just('hget'), keys, fields) + | commands(st.sampled_from(['hgetall', 'hkeys', 'hvals']), keys) + | commands(st.just('hincrby'), keys, fields, st.integers()) + | commands(st.just('hlen'), keys) + | commands(st.just('hmget'), keys, st.lists(fields)) + | commands(st.sampled_from(['hset', 'hmset']), keys, st.lists(st.tuples(fields, values))) + | commands(st.just('hsetnx'), keys, fields, values) + | commands(st.just('hstrlen'), keys, fields) + ) + create_command_strategy = ( + commands(st.just('hmset'), keys, st.lists(st.tuples(fields, values), min_size=1)) + ) + command_strategy = hash_commands | common_commands + + +class TestList(BaseTest): + # TODO: blocking commands + list_commands = ( + commands(st.just('lindex'), keys, counts) + | commands(st.just('linsert'), keys, + st.sampled_from(['before', 'after', 'BEFORE', 'AFTER']) | st.binary(), + values, values) + | commands(st.just('llen'), keys) + | commands(st.sampled_from(['lpop', 'rpop']), keys, st.just(None) | st.just([]) | st.integers()) + | commands(st.sampled_from(['lpush', 'lpushx', 'rpush', 'rpushx']), keys, st.lists(values)) + | commands(st.just('lrange'), keys, counts, counts) + | commands(st.just('lrem'), keys, counts, values) + | commands(st.just('lset'), keys, counts, values) + | commands(st.just('ltrim'), keys, counts, counts) + | commands(st.just('rpoplpush'), keys, keys) + ) + create_command_strategy = commands(st.just('rpush'), keys, st.lists(values, min_size=1)) + command_strategy = list_commands | common_commands + + +class TestSet(BaseTest): + set_commands = ( + commands(st.just('sadd'), keys, st.lists(fields, )) + | commands(st.just('scard'), keys) + | commands(st.sampled_from(['sdiff', 'sinter', 'sunion']), st.lists(keys)) + | commands(st.sampled_from(['sdiffstore', 'sinterstore', 'sunionstore']), + keys, st.lists(keys)) + | commands(st.just('sismember'), keys, fields) + | commands(st.just('smembers'), keys) + | commands(st.just('smove'), keys, keys, fields) + | commands(st.just('srem'), keys, st.lists(fields)) + ) + # TODO: + # - find a way to test srandmember, spop which are random + # - sscan + create_command_strategy = ( + commands(st.just('sadd'), keys, st.lists(fields, min_size=1)) + ) + command_strategy = set_commands | common_commands + + +class TestZSet(BaseTest): + zset_commands = ( + commands(st.just('zadd'), keys, + st.none() | st.just('nx'), + st.none() | st.just('xx'), + st.none() | st.just('ch'), + st.none() | st.just('incr'), + st.lists(st.tuples(scores, fields))) + | commands(st.just('zcard'), keys) + | commands(st.just('zcount'), keys, score_tests, score_tests) + | commands(st.just('zincrby'), keys, scores, fields) + | commands(st.sampled_from(['zrange', 'zrevrange']), keys, counts, counts, + st.none() | st.just('withscores')) + | commands(st.sampled_from(['zrangebyscore', 'zrevrangebyscore']), + keys, score_tests, score_tests, + limits, + st.none() | st.just('withscores')) + | commands(st.sampled_from(['zrank', 'zrevrank']), keys, fields) + | commands(st.just('zrem'), keys, st.lists(fields)) + | commands(st.just('zremrangebyrank'), keys, counts, counts) + | commands(st.just('zremrangebyscore'), keys, score_tests, score_tests) + | commands(st.just('zscore'), keys, fields) + | st.builds(build_zstore, + command=st.sampled_from(['zunionstore', 'zinterstore']), + dest=keys, sources=st.lists(st.tuples(keys, float_as_bytes)), + weights=st.booleans(), + aggregate=st.sampled_from([None, 'sum', 'min', 'max'])) + ) + # TODO: zscan, zpopmin/zpopmax, bzpopmin/bzpopmax, probably more + create_command_strategy = ( + commands(st.just('zadd'), keys, st.lists(st.tuples(scores, fields), min_size=1)) + ) + command_strategy = zset_commands | common_commands + + +class TestZSetNoScores(BaseTest): + create_command_strategy = zset_no_score_create_commands + command_strategy = zset_no_score_commands | common_commands + + +class TestTransaction(BaseTest): + transaction_commands = ( + commands(st.sampled_from(['multi', 'discard', 'exec', 'unwatch'])) + | commands(st.just('watch'), keys) + ) + create_command_strategy = TestString.create_command_strategy + command_strategy = transaction_commands | TestString.string_commands | common_commands + + +class TestServer(BaseTest): + # TODO: real redis raises an error if there is a save already in progress. + # Find a better way to test this. + # commands(st.just('bgsave')) + server_commands = ( + commands(st.just('dbsize')) + | commands(st.sampled_from(['flushdb', 'flushall']), st.sampled_from([[], 'async'])) + # TODO: result is non-deterministic + # | commands(st.just('lastsave')) + | commands(st.just('save')) + ) + create_command_strategy = TestString.create_command_strategy + command_strategy = server_commands | TestString.string_commands | common_commands + + +class TestJoint(BaseTest): + create_command_strategy = ( + TestString.create_command_strategy + | TestHash.create_command_strategy | TestList.create_command_strategy + | TestSet.create_command_strategy + | TestZSet.create_command_strategy) + command_strategy = ( + TestServer.server_commands + | TestConnection.connection_commands + | TestString.string_commands + | TestHash.hash_commands + | TestList.list_commands + | TestSet.set_commands + | TestZSet.zset_commands | common_commands | bad_commands) + + +@st.composite +def delete_arg(draw, commands): + command = draw(commands) + if command.args: + pos = draw(st.integers(min_value=0, max_value=len(command.args) - 1)) + command.args = command.args[:pos] + command.args[pos + 1:] + return command + + +@st.composite +def command_args(draw, commands): + """Generate an argument from some command""" + command = draw(commands) + hypothesis.assume(len(command.args)) + return draw(st.sampled_from(command.args)) + + +def mutate_arg(draw, commands, mutate): + command = draw(commands) + if command.args: + pos = draw(st.integers(min_value=0, max_value=len(command.args) - 1)) + arg = mutate(Command.encode(command.args[pos])) + command.args = command.args[:pos] + (arg,) + command.args[pos + 1:] + return command + + +@st.composite +def replace_arg(draw, commands, replacements): + return mutate_arg(draw, commands, lambda arg: draw(replacements)) + + +@st.composite +def uppercase_arg(draw, commands): + return mutate_arg(draw, commands, lambda arg: arg.upper()) + + +@st.composite +def prefix_arg(draw, commands, prefixes): + return mutate_arg(draw, commands, lambda arg: draw(prefixes) + arg) + + +@st.composite +def suffix_arg(draw, commands, suffixes): + return mutate_arg(draw, commands, lambda arg: arg + draw(suffixes)) + + +@st.composite +def add_arg(draw, commands, arguments): + command = draw(commands) + arg = draw(arguments) + pos = draw(st.integers(min_value=0, max_value=len(command.args))) + command.args = command.args[:pos] + (arg,) + command.args[pos:] + return command + + +@st.composite +def swap_args(draw, commands): + command = draw(commands) + if len(command.args) >= 2: + pos1 = draw(st.integers(min_value=0, max_value=len(command.args) - 1)) + pos2 = draw(st.integers(min_value=0, max_value=len(command.args) - 1)) + hypothesis.assume(pos1 != pos2) + args = list(command.args) + arg1 = args[pos1] + arg2 = args[pos2] + args[pos1] = arg2 + args[pos2] = arg1 + command.args = tuple(args) + return command + + +def mutated_commands(commands): + args = st.sampled_from([b'withscores', b'xx', b'nx', b'ex', b'px', b'weights', b'aggregate', + b'', b'0', b'-1', b'nan', b'inf', b'-inf']) | command_args(commands) + affixes = st.sampled_from([b'\0', b'-', b'+', b'\t', b'\n', b'0000']) | st.binary() + return st.recursive( + commands, + lambda x: + delete_arg(x) + | replace_arg(x, args) + | uppercase_arg(x) + | prefix_arg(x, affixes) + | suffix_arg(x, affixes) + | add_arg(x, args) + | swap_args(x)) + + +class TestFuzz(BaseTest): + command_strategy = mutated_commands(TestJoint.command_strategy) + command_strategy = command_strategy.filter(lambda command: command.testable) diff -Nru fakeredis-2.4.0/test/test_init_args.py fakeredis-2.10.3/test/test_init_args.py --- fakeredis-2.4.0/test/test_init_args.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_init_args.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,130 @@ +import pytest + +import fakeredis + + +def test_multidb(r, create_redis): + r1 = create_redis(db=0) + r2 = create_redis(db=1) + + r1['r1'] = 'r1' + r2['r2'] = 'r2' + + assert 'r2' not in r1 + assert 'r1' not in r2 + + assert r1['r1'] == b'r1' + assert r2['r2'] == b'r2' + + assert r1.flushall() is True + + assert 'r1' not in r1 + assert 'r2' not in r2 + + +@pytest.mark.fake +class TestInitArgs: + def test_singleton(self): + shared_server = fakeredis.FakeServer() + r1 = fakeredis.FakeStrictRedis() + r2 = fakeredis.FakeStrictRedis() + r3 = fakeredis.FakeStrictRedis(server=shared_server) + r4 = fakeredis.FakeStrictRedis(server=shared_server) + + r1.set('foo', 'bar') + r3.set('bar', 'baz') + + assert 'foo' in r1 + assert 'foo' not in r2 + assert 'foo' not in r3 + + assert 'bar' in r3 + assert 'bar' in r4 + assert 'bar' not in r1 + + def test_host_init_arg(self): + db = fakeredis.FakeStrictRedis(host='localhost') + db.set('foo', 'bar') + assert db.get('foo') == b'bar' + + def test_from_url(self): + db = fakeredis.FakeStrictRedis.from_url( + 'redis://localhost:6379/0') + db.set('foo', 'bar') + assert db.get('foo') == b'bar' + + def test_from_url_user(self): + db = fakeredis.FakeStrictRedis.from_url( + 'redis://user@localhost:6379/0') + db.set('foo', 'bar') + assert db.get('foo') == b'bar' + + def test_from_url_user_password(self): + db = fakeredis.FakeStrictRedis.from_url( + 'redis://user:password@localhost:6379/0') + db.set('foo', 'bar') + assert db.get('foo') == b'bar' + + def test_from_url_with_db_arg(self): + db = fakeredis.FakeStrictRedis.from_url( + 'redis://localhost:6379/0') + db1 = fakeredis.FakeStrictRedis.from_url( + 'redis://localhost:6379/1') + db2 = fakeredis.FakeStrictRedis.from_url( + 'redis://localhost:6379/', + db=2) + db.set('foo', 'foo0') + db1.set('foo', 'foo1') + db2.set('foo', 'foo2') + assert db.get('foo') == b'foo0' + assert db1.get('foo') == b'foo1' + assert db2.get('foo') == b'foo2' + + def test_from_url_db_value_error(self): + # In case of ValueError, should default to 0, or be absent in redis-py 4.0 + db = fakeredis.FakeStrictRedis.from_url( + 'redis://localhost:6379/a') + assert db.connection_pool.connection_kwargs.get('db', 0) == 0 + + def test_can_pass_through_extra_args(self): + db = fakeredis.FakeStrictRedis.from_url( + 'redis://localhost:6379/0', + decode_responses=True) + db.set('foo', 'bar') + assert db.get('foo') == 'bar' + + def test_can_allow_extra_args(self): + db = fakeredis.FakeStrictRedis.from_url( + 'redis://localhost:6379/0', + socket_connect_timeout=11, socket_timeout=12, socket_keepalive=True, + socket_keepalive_options={60: 30}, socket_type=1, + retry_on_timeout=True, + ) + fake_conn = db.connection_pool.make_connection() + assert fake_conn.socket_connect_timeout == 11 + assert fake_conn.socket_timeout == 12 + assert fake_conn.socket_keepalive is True + assert fake_conn.socket_keepalive_options == {60: 30} + assert fake_conn.socket_type == 1 + assert fake_conn.retry_on_timeout is True + + # Make fallback logic match redis-py + db = fakeredis.FakeStrictRedis.from_url( + 'redis://localhost:6379/0', + socket_connect_timeout=None, socket_timeout=30 + ) + fake_conn = db.connection_pool.make_connection() + assert fake_conn.socket_connect_timeout == fake_conn.socket_timeout + assert fake_conn.socket_keepalive_options == {} + + def test_repr(self): + # repr is human-readable, so we only test that it doesn't crash, + # and that it contains the db number. + db = fakeredis.FakeStrictRedis.from_url('redis://localhost:6379/11') + rep = repr(db) + assert 'db=11' in rep + + def test_from_unix_socket(self): + db = fakeredis.FakeStrictRedis.from_url('unix://a/b/c') + db.set('foo', 'bar') + assert db.get('foo') == b'bar' diff -Nru fakeredis-2.4.0/test/test_json/test_json_arr_commands.py fakeredis-2.10.3/test/test_json/test_json_arr_commands.py --- fakeredis-2.4.0/test/test_json/test_json_arr_commands.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_json/test_json_arr_commands.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,305 @@ +import pytest +import redis +from redis.commands.json.path import Path + +json_tests = pytest.importorskip("jsonpath_ng") + + +def test_arrlen(r: redis.Redis) -> None: + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + assert r.json().arrlen("arr", Path.root_path(), ) == 5 + assert r.json().arrlen("arr") == 5 + assert r.json().arrlen("fake-key") is None + + r.json().set("doc1", Path.root_path(), + {"a": ["foo"], "nested1": {"a": ["hello", None, "world"]}, "nested2": {"a": 31}, }) + + assert r.json().arrlen("doc1", "$..a") == [1, 3, None] + assert r.json().arrlen("doc1", "$.nested1.a") == [3] + + r.json().set("doc2", "$", {"a": ["foo"], "nested1": {"a": ["hello", 1, 1, None, "world"]}, "nested2": {"a": 31}, }) + assert r.json().arrlen("doc2", "$..a") == [1, 5, None] + assert r.json().arrlen("doc2", ".nested1.a") == 5 + r.json().set( + "doc1", "$", { + "a": ["foo"], "nested1": {"a": ["hello", None, "world"]}, "nested2": {"a": 31}, }, ) + + # Test multi + assert r.json().arrlen("doc1", "$..a") == [1, 3, None] + assert r.json().arrappend("doc1", "$..a", "non", "abba", "stanza") == [ + 4, 6, None, ] + + r.json().clear("doc1", "$.a") + assert r.json().arrlen("doc1", "$..a") == [0, 6, None] + # Test single + assert r.json().arrlen("doc1", "$.nested1.a") == [6] + + # Test missing key + with pytest.raises(redis.ResponseError): + r.json().arrappend("non_existing_doc", "$..a") + + r.json().set( + "doc1", "$", { + "a": ["foo"], "nested1": {"a": ["hello", None, "world"]}, "nested2": {"a": 31}, }, ) + # Test multi (return result of last path) + assert r.json().arrlen("doc1", "$..a") == [1, 3, None] + assert r.json().arrappend("doc1", "..a", "non", "abba", "stanza") == 6 + + # Test single + assert r.json().arrlen("doc1", ".nested1.a") == 6 + + # Test missing key + assert r.json().arrlen("non_existing_doc", "..a") is None + + +def test_arrappend(r: redis.Redis): + with pytest.raises(redis.ResponseError): + r.json().arrappend("non-existing-key", Path.root_path(), 2) + + r.json().set("arr", Path.root_path(), [1]) + assert r.json().arrappend("arr", Path.root_path(), 2) == 2 + assert r.json().arrappend("arr", Path.root_path(), 3, 4) == 4 + assert r.json().arrappend("arr", Path.root_path(), *[5, 6, 7]) == 7 + assert r.json().get("arr") == [1, 2, 3, 4, 5, 6, 7] + r.json().set( + "doc1", "$", { + "a": ["foo"], "nested1": {"a": ["hello", None, "world"]}, "nested2": {"a": 31}, }, ) + # Test multi + assert r.json().arrappend("doc1", "$..a", "bar", "racuda") == [3, 5, None] + assert r.json().get("doc1", "$") == [{ + "a": ["foo", "bar", "racuda"], "nested1": {"a": ["hello", None, "world", "bar", "racuda"]}, + "nested2": {"a": 31}, }] + assert r.json().arrappend("doc1", "$.nested1.a", "baz") == [6] + + # Test legacy + r.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 r.json().arrappend("doc1", "..a", "bar", "racuda") == 5 + + assert r.json().get("doc1", "$") == [{ + "a": ["foo", "bar", "racuda"], "nested1": {"a": ["hello", None, "world", "bar", "racuda"]}, + "nested2": {"a": 31}, }] + # Test single + assert r.json().arrappend("doc1", ".nested1.a", "baz") == 6 + assert r.json().get("doc1", "$") == [{ + "a": ["foo", "bar", "racuda"], "nested1": {"a": ["hello", None, "world", "bar", "racuda", "baz"]}, + "nested2": {"a": 31}, }] + + # Test missing key + with pytest.raises(redis.ResponseError): + r.json().arrappend("non_existing_doc", "$..a") + + +def test_arrindex(r: redis.Redis) -> None: + r.json().set("foo", Path.root_path(), [0, 1, 2, 3, 4], ) + + assert r.json().arrindex("foo", Path.root_path(), 1) == 1 + assert r.json().arrindex("foo", Path.root_path(), 1, 2) == -1 + + r.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 r.json().get("store", "$.store.book[?(@.price<10)].size") == [ + [10, 20, 30, 40], [5, 10, 20, 30], ] + assert r.json().arrindex("store", "$.store.book[?(@.price<10)].size", "20") == [-1, -1] + + # Test index of int scalar in multi values + r.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 r.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 r.json().arrindex("test_num", "$..nonexistingpath", 3) == [] + assert r.json().arrindex("test_num", "$..arr", 3) == [3, 2, -1, None, -1] + + # Test index of double scalar in multi values + assert r.json().arrindex("test_num", "$..arr", 3.0) == [2, 8, -1, None, -1] + + # Test index of string scalar in multi values + r.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 r.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 r.json().arrindex("test_string", "$..arr", "baz") == [3, 8, -1, None, -1, ] + + assert r.json().arrindex("test_string", "$..arr", "baz", 2) == [3, 8, -1, None, -1, ] + assert r.json().arrindex("test_string", "$..arr", "baz", 4) == [6, 8, -1, None, -1, ] + assert r.json().arrindex("test_string", "$..arr", "baz", -5) == [3, 8, -1, None, -1, ] + assert r.json().arrindex("test_string", "$..arr", "baz", 4, 7) == [6, -1, -1, None, -1, ] + assert r.json().arrindex("test_string", "$..arr", "baz", 4, -1) == [6, 8, -1, None, -1, ] + assert r.json().arrindex("test_string", "$..arr", "baz", 4, 0) == [6, 8, -1, None, -1, ] + assert r.json().arrindex("test_string", "$..arr", "5", 7, -1) == [-1, -1, -1, None, -1, ] + assert r.json().arrindex("test_string", "$..arr", "5", 7, 0) == [-1, -1, -1, None, -1, ] + + # Test index of None scalar in multi values + r.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 r.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, [], ] + + # Test with none-scalar value + # assert r.json().arrindex("test_None", "$..nested42_empty_arr.arr", {"arr": []}) == [-1] + + # Test legacy (path begins with dot) + # Test index of int scalar in single value + assert r.json().arrindex("test_num", ".[0].arr", 3) == 3 + assert r.json().arrindex("test_num", ".[0].arr", 9) == -1 + + with pytest.raises(redis.ResponseError): + r.json().arrindex("test_num", ".[0].arr_not", 3) + # Test index of string scalar in single value + assert r.json().arrindex("test_string", ".[0].arr", "baz") == 3 + assert r.json().arrindex("test_string", ".[0].arr", "faz") == -1 + # Test index of None scalar in single value + assert r.json().arrindex("test_None", ".[0].arr", "None") == 1 + assert r.json().arrindex("test_None", "..nested2_not_found.arr", "None") == 0 + + +def test_arrinsert(r: redis.Redis) -> None: + r.json().set("arr", Path.root_path(), [0, 4], ) + + assert r.json().arrinsert("arr", Path.root_path(), 1, *[1, 2, 3], ) == 5 + assert r.json().get("arr") == [0, 1, 2, 3, 4] + + # test prepends + r.json().set("val2", Path.root_path(), [5, 6, 7, 8, 9], ) + assert r.json().arrinsert("val2", Path.root_path(), 0, ["some", "thing"], ) == 6 + assert r.json().get("val2") == [["some", "thing"], 5, 6, 7, 8, 9] + r.json().set("doc1", "$", {"a": ["foo"], "nested1": {"a": ["hello", None, "world"]}, "nested2": {"a": 31}, }) + # Test multi + assert r.json().arrinsert("doc1", "$..a", "1", "bar", "racuda") == [3, 5, None] + + assert r.json().get("doc1", "$") == [{ + "a": ["foo", "bar", "racuda"], "nested1": {"a": ["hello", "bar", "racuda", None, "world"]}, + "nested2": {"a": 31}, }] + # Test single + assert r.json().arrinsert("doc1", "$.nested1.a", -2, "baz") == [6] + assert r.json().get("doc1", "$") == [{ + "a": ["foo", "bar", "racuda"], "nested1": {"a": ["hello", "bar", "racuda", "baz", None, "world"]}, + "nested2": {"a": 31}, }] + + # Test missing key + with pytest.raises(redis.ResponseError): + r.json().arrappend("non_existing_doc", "$..a") + + +def test_arrpop(r: redis.Redis) -> None: + # todo fix failing raw_command + # r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + # assert raw_command(r, 'json.arrpop', 'arr') == b'4' + + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + assert r.json().arrpop("arr", Path.root_path(), 4, ) == 4 + assert r.json().arrpop("arr", Path.root_path(), -1, ) == 3 + assert r.json().arrpop("arr", Path.root_path(), ) == 2 + assert r.json().arrpop("arr", Path.root_path(), 0, ) == 0 + assert r.json().get("arr") == [1] + + # test out of bounds + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + assert r.json().arrpop("arr", Path.root_path(), 99, ) == 4 + + # none test + r.json().set("arr", Path.root_path(), [], ) + assert r.json().arrpop("arr") is None + + r.json().set("doc1", "$", {"a": ["foo"], "nested1": {"a": ["hello", None, "world"]}, "nested2": {"a": 31}, }) + + # # # Test multi + assert r.json().arrpop("doc1", "$..a", 1) == ['"foo"', None, None] + assert r.json().get("doc1", "$") == [{"a": [], "nested1": {"a": ["hello", "world"]}, "nested2": {"a": 31}}] + + # Test missing key + with pytest.raises(redis.ResponseError): + r.json().arrpop("non_existing_doc", "..a") + + # # Test legacy + r.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 r.json().arrpop("doc1", "..a", "1") is None + assert r.json().get("doc1", "$") == [{"a": [], "nested1": {"a": ["hello", "world"]}, "nested2": {"a": 31}}] + + # # Test missing key + with pytest.raises(redis.ResponseError): + r.json().arrpop("non_existing_doc", "..a") + + +def test_arrtrim(r: redis.Redis) -> None: + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + + assert r.json().arrtrim("arr", Path.root_path(), 1, 3, ) == 3 + assert r.json().get("arr") == [1, 2, 3] + + # <0 test, should be 0 equivalent + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + assert r.json().arrtrim("arr", Path.root_path(), -1, 3, ) == 0 + + # testing stop > end + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + assert r.json().arrtrim("arr", Path.root_path(), 3, 99, ) == 2 + + # start > array size and stop + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + assert r.json().arrtrim("arr", Path.root_path(), 9, 1, ) == 0 + + # all larger + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + assert r.json().arrtrim("arr", Path.root_path(), 9, 11, ) == 0 + + r.json().set("doc1", "$", {"a": ["foo"], "nested1": {"a": ["hello", None, "world"]}, "nested2": {"a": 31}, }) + # Test multi + assert r.json().arrtrim("doc1", "$..a", "1", -1) == [0, 2, None] + assert r.json().get("doc1", "$") == [{"a": [], "nested1": {"a": [None, "world"]}, "nested2": {"a": 31}}] + + r.json().set('doc1', '$', {"a": [], "nested1": {"a": [None, "world"]}, "nested2": {"a": 31}}) + assert r.json().arrtrim("doc1", "$..a", "1", "1") == [0, 1, None] + assert r.json().get("doc1", "$") == [{"a": [], "nested1": {"a": ["world"]}, "nested2": {"a": 31}}] + # Test single + assert r.json().arrtrim("doc1", "$.nested1.a", 1, 0) == [0] + assert r.json().get("doc1", "$") == [{"a": [], "nested1": {"a": []}, "nested2": {"a": 31}}] + + # Test missing key + with pytest.raises(redis.ResponseError): + r.json().arrtrim("non_existing_doc", "..a", "0", 1) + + # Test legacy + r.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 r.json().arrtrim("doc1", "..a", "1", "-1") == 2 + + # Test single + assert r.json().arrtrim("doc1", ".nested1.a", "1", "1") == 1 + assert r.json().get("doc1", "$") == [ + {"a": [], "nested1": {"a": ["world"]}, "nested2": {"a": 31}} + ] + + # Test missing key + with pytest.raises(redis.ResponseError): + r.json().arrtrim("non_existing_doc", "..a", 1, 1) diff -Nru fakeredis-2.4.0/test/test_json/test_json_commands.py fakeredis-2.10.3/test/test_json/test_json_commands.py --- fakeredis-2.4.0/test/test_json/test_json_commands.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_json/test_json_commands.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,231 @@ +"""Tests for `fakeredis-py`'s emulation of Redis's JSON command subset.""" + +from __future__ import annotations + +import pytest +import redis +from redis.commands.json.path import Path +from typing import (Any, Dict, List, Tuple, ) + +json_tests = pytest.importorskip("jsonpath_ng") + +SAMPLE_DATA = { + "a": ["foo"], + "nested1": {"a": ["hello", None, "world"]}, + "nested2": {"a": 31}, +} + + +@pytest.fixture(scope="function") +def json_data() -> Dict[str, Any]: + """A module-scoped "blob" of JSON-encodable data.""" + return { + "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"}, + } + }, + } + + +@pytest.mark.xfail +def test_debug(r: redis.Redis) -> None: + r.json().set("str", Path.root_path(), "foo") + assert 24 == r.json().debug("MEMORY", "str", Path.root_path()) + assert 24 == r.json().debug("MEMORY", "str") + + # technically help is valid + assert isinstance(r.json().debug("HELP"), list) + + +@pytest.mark.xfail +def test_resp(r: redis.Redis) -> None: + obj = {"foo": "bar", "baz": 1, "qaz": True, } + r.json().set("obj", Path.root_path(), obj, ) + + assert "bar" == r.json().resp("obj", Path("foo"), ) + assert 1 == r.json().resp("obj", Path("baz"), ) + assert r.json().resp( + "obj", + Path("qaz"), + ) + assert isinstance(r.json().resp("obj"), list) + + +def load_types_data(nested_key_name: str) -> Tuple[Dict[str, Any], List[str]]: + """Generate a structure with sample of all types + """ + type_samples = { + "object": {}, + "array": [], + "string": "str", + "integer": 42, + "number": 1.2, + "boolean": False, + "null": None, + } + jdata = {} + + for (k, v) in type_samples.items(): + jdata[f"nested_{k}"] = {nested_key_name: v} + + return jdata, [k.encode() for k in type_samples.keys()] + + +@pytest.mark.xfail +def test_debug_dollar(r: redis.Redis) -> None: + jdata, jtypes = load_types_data("a") + + r.json().set("doc1", "$", jdata) + + # Test multi + assert r.json().debug("MEMORY", "doc1", "$..a") == [72, 24, 24, 16, 16, 1, 0] + + # Test single + assert r.json().debug("MEMORY", "doc1", "$.nested2.a") == [24] + + # Test legacy + assert r.json().debug("MEMORY", "doc1", "..a") == 72 + + # Test missing path (defaults to root) + assert r.json().debug("MEMORY", "doc1") == 72 + + # Test missing key + assert r.json().debug("MEMORY", "non_existing_doc", "$..a") == [] + + +@pytest.mark.xfail +def test_resp_dollar(r: redis.Redis, json_data: Dict[str, Any]) -> None: + r.json().set("doc1", "$", json_data) + + # Test multi + res = r.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 = r.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 + r.json().resp("doc1", "$.nowhere") + + # Test missing key + # with pytest.raises(exceptions.ResponseError): + r.json().resp("non_existing_doc", "$..a") diff -Nru fakeredis-2.4.0/test/test_json/test_json.py fakeredis-2.10.3/test/test_json/test_json.py --- fakeredis-2.4.0/test/test_json/test_json.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_json/test_json.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,559 @@ +""" +Tests for `fakeredis-py`'s emulation of Redis's JSON.GET command subset. +""" + +from __future__ import annotations + +import json +import pytest +import redis +from redis.commands.json.path import Path + +from test.testtools import raw_command + +json_tests = pytest.importorskip("jsonpath_ng") + + +def test_jsonget(r: redis.Redis): + data = {'x': "bar", 'y': {'x': 33}} + r.json().set("foo", Path.root_path(), data) + assert r.json().get("foo") == data + assert r.json().get("foo", Path("$..x")) == ['bar', 33] + + data2 = {'x': "bar"} + r.json().set("foo2", Path.root_path(), data2, ) + assert r.json().get("foo2") == data2 + assert r.json().get("foo2", "$") == [data2, ] + assert r.json().get("foo2", Path("$.a"), Path("$.x")) == {'$.a': [], '$.x': ['bar']} + + assert r.json().get("non-existing-key") is None + + r.json().set("foo2", Path.root_path(), {'x': "bar", 'y': {'x': 33}}, ) + assert r.json().get("foo2") == {'x': "bar", 'y': {'x': 33}} + assert r.json().get("foo2", Path("$..x")) == ['bar', 33] + + r.json().set("foo", Path.root_path(), {'x': "bar"}, ) + assert r.json().get("foo") == {'x': "bar"} + assert r.json().get("foo", Path("$.a"), Path("$.x")) == {'$.a': [], '$.x': ['bar']} + + +def test_json_setgetdeleteforget(r: redis.Redis) -> None: + data = {'x': "bar"} + assert r.json().set("foo", Path.root_path(), data) == 1 + assert r.json().get("foo") == data + assert r.json().get("baz") is None + assert r.json().delete("foo") == 1 + assert r.json().forget("foo") == 0 # second delete + assert r.exists("foo") == 0 + + +def test_json_delete_with_dollar(r: redis.Redis) -> None: + doc1 = {"a": 1, "nested": {"a": 2, "b": 3}} + assert r.json().set("doc1", Path.root_path(), doc1) + assert r.json().delete("doc1", "$..a") == 2 + assert r.json().get("doc1", Path.root_path()) == {"nested": {"b": 3}} + + doc2 = {"a": {"a": 2, "b": 3}, "b": ["a", "b"], "nested": {"b": [True, "a", "b"]}} + r.json().set("doc2", "$", doc2) + assert r.json().delete("doc2", "$..a") == 1 + assert r.json().get("doc2", Path.root_path()) == {"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 r.json().set("doc3", Path.root_path(), doc3) + assert r.json().delete("doc3", '$.[0]["nested"]..ciao') == 3 + + doc3val = [[{ + "ciao": ["non ancora"], + "nested": [ + {}, {}, {"ciaoc": [3, "non", "ciao"]}, {}, {"e": [5, "non", "ciao"]}, + ], + }]] + assert r.json().get("doc3", Path.root_path()) == doc3val[0] + + # Test default path + assert r.json().delete("doc3") == 1 + assert r.json().get("doc3", Path.root_path()) is None + + r.json().delete("not_a_document", "..a") + + +def test_json_et_non_dict_value(r: redis.Redis): + r.json().set("str", Path.root_path(), 'str_val', ) + assert r.json().get('str') == 'str_val' + + r.json().set("bool", Path.root_path(), True) + assert r.json().get('bool') == True + + r.json().set("bool", Path.root_path(), False) + assert r.json().get('bool') == False + + +def test_jsonset_existential_modifiers_should_succeed(r: redis.Redis) -> None: + obj = {"foo": "bar"} + assert r.json().set("obj", Path.root_path(), obj) + + # Test that flags prevent updates when conditions are unmet + assert r.json().set("obj", Path("foo"), "baz", nx=True, ) is None + assert r.json().get("obj") == obj + + assert r.json().set("obj", Path("qaz"), "baz", xx=True, ) is None + assert r.json().get("obj") == obj + + # Test that flags allow updates when conditions are met + assert r.json().set("obj", Path("foo"), "baz", xx=True) == 1 + assert r.json().set("obj", Path("foo2"), "qaz", nx=True) == 1 + assert r.json().get("obj") == {"foo": "baz", "foo2": "qaz"} + + # Test with raw + obj = {"foo": "bar"} + raw_command(r, 'json.set', 'obj', '$', json.dumps(obj)) + assert r.json().get('obj') == obj + + +def test_jsonset_flags_should_be_mutually_exclusive(r: redis.Redis): + with pytest.raises(Exception): + r.json().set("obj", Path("foo"), "baz", nx=True, xx=True) + with pytest.raises(redis.ResponseError): + raw_command(r, 'json.set', 'obj', '$', json.dumps({"foo": "bar"}), 'NX', 'XX') + + +def test_json_unknown_param(r: redis.Redis): + with pytest.raises(redis.ResponseError): + raw_command(r, 'json.set', 'obj', '$', json.dumps({"foo": "bar"}), 'unknown') + + +def test_jsonmget(r: redis.Redis): + # Test mget with multi paths + r.json().set("doc1", "$", {"a": 1, "b": 2, "nested": {"a": 3}, "c": None, "nested2": {"a": None}}) + r.json().set("doc2", "$", {"a": 4, "b": 5, "nested": {"a": 6}, "c": None, "nested2": {"a": [None]}}) + r.json().set("doc3", "$", {"a": 5, "b": 5, "nested": {"a": 8}, "c": None, "nested2": {"a": {"b": "nested3"}}}) + # Compare also to single JSON.GET + assert r.json().get("doc1", Path("$..a")) == [1, 3, None] + assert r.json().get("doc2", "$..a") == [4, 6, [None]] + assert r.json().get("doc3", "$..a") == [5, 8, {"b": "nested3"}] + + # Test mget with single path + assert r.json().mget(["doc1"], "$..a") == [[1, 3, None]] + + # Test mget with multi path + assert r.json().mget(["doc1", "doc2", "doc3"], "$..a") == [[1, 3, None], [4, 6, [None]], [5, 8, {"b": "nested3"}]] + + # Test missing key + assert r.json().mget(["doc1", "missing_doc"], "$..a") == [[1, 3, None], None] + + assert r.json().mget(["missing_doc1", "missing_doc2"], "$..a") == [None, None] + + +def test_jsonmget_should_succeed(r: redis.Redis) -> None: + r.json().set("1", Path.root_path(), 1) + r.json().set("2", Path.root_path(), 2) + + assert r.json().mget(["1"], Path.root_path()) == [1] + + assert r.json().mget([1, 2], Path.root_path()) == [1, 2] + + +def test_jsonclear(r: redis.Redis) -> None: + r.json().set("arr", Path.root_path(), [0, 1, 2, 3, 4], ) + + assert 1 == r.json().clear("arr", Path.root_path(), ) + assert [] == r.json().get("arr") + + +def test_jsonclear_dollar(r: redis.Redis) -> None: + data = { + "nested1": {"a": {"foo": 10, "bar": 20}}, + "a": ["foo"], + "nested2": {"a": "claro"}, + "nested3": {"a": {"baz": 50}} + } + r.json().set("doc1", "$", data) + # Test multi + assert r.json().clear("doc1", "$..a") == 3 + + assert r.json().get("doc1", "$") == [ + {"nested1": {"a": {}}, "a": [], "nested2": {"a": "claro"}, "nested3": {"a": {}}} + ] + + # Test single + r.json().set("doc1", "$", data) + assert r.json().clear("doc1", "$.nested1.a") == 1 + assert r.json().get("doc1", "$") == [ + { + "nested1": {"a": {}}, + "a": ["foo"], + "nested2": {"a": "claro"}, + "nested3": {"a": {"baz": 50}}, + } + ] + + # Test missing path (defaults to root) + assert r.json().clear("doc1") == 1 + assert r.json().get("doc1", "$") == [{}] + + +def test_jsonclear_no_doc(r: redis.Redis) -> None: + # Test missing key + with pytest.raises(redis.ResponseError): + r.json().clear("non_existing_doc", "$..a") + + +def test_jsonstrlen(r: redis.Redis): + data = {'x': "bar", 'y': {'x': 33}} + r.json().set("foo", Path.root_path(), data) + assert r.json().strlen("foo", Path("$..x")) == [3, None] + + r.json().set("foo2", Path.root_path(), "data2") + assert r.json().strlen('foo2') == 5 + assert r.json().strlen('foo2', Path.root_path()) == 5 + + r.json().set("foo3", Path.root_path(), {'x': 'string'}) + assert r.json().strlen("foo3", Path("$.x")) == [6, ] + + assert r.json().strlen('non-existing') is None + + r.json().set("str", Path.root_path(), "foo") + assert r.json().strlen("str", Path.root_path()) == 3 + # Test multi + r.json().set("doc1", "$", {"a": "foo", "nested1": {"a": "hello"}, "nested2": {"a": 31}}) + assert r.json().strlen("doc1", "$..a") == [3, 5, None] + + res2 = r.json().strappend("doc1", "bar", "$..a") + res1 = r.json().strlen("doc1", "$..a") + assert res1 == res2 + + # Test single + assert r.json().strlen("doc1", "$.nested1.a") == [8] + assert r.json().strlen("doc1", "$.nested2.a") == [None] + + # Test missing key + with pytest.raises(redis.ResponseError): + r.json().strlen("non_existing_doc", "$..a") + + +def test_toggle(r: redis.Redis) -> None: + r.json().set("bool", Path.root_path(), False) + assert r.json().toggle("bool", Path.root_path()) + assert r.json().toggle("bool", Path.root_path()) is False + + r.json().set("num", Path.root_path(), 1) + + with pytest.raises(redis.exceptions.ResponseError): + r.json().toggle("num", Path.root_path()) + + +def test_toggle_dollar(r: redis.Redis) -> None: + data = { + "a": ["foo"], + "nested1": {"a": False}, + "nested2": {"a": 31}, + "nested3": {"a": True}, + } + r.json().set("doc1", "$", data) + # Test multi + assert r.json().toggle("doc1", "$..a") == [None, 1, None, 0] + data['nested1']['a'] = True + data['nested3']['a'] = False + assert r.json().get("doc1", "$") == [data] + + # Test missing key + with pytest.raises(redis.exceptions.ResponseError): + r.json().toggle("non_existing_doc", "$..a") + + +def test_json_commands_in_pipeline(r: redis.Redis) -> None: + p = r.json().pipeline() + p.set("foo", Path.root_path(), "bar") + p.get("foo") + p.delete("foo") + assert [True, "bar", 1] == p.execute() + assert r.keys() == [] + assert r.get("foo") is None + + # now with a true, json object + r.flushdb() + p = r.json().pipeline() + d = {"hello": "world", "oh": "snap"} + + with pytest.deprecated_call(): + p.jsonset("foo", Path.root_path(), d) + p.jsonget("foo") + + p.exists("not-a-real-key") + p.delete("foo") + + assert [True, d, 0, 1] == p.execute() + assert r.keys() == [] + assert r.get("foo") is None + + +def test_strappend(r: redis.Redis) -> None: + # Test single + r.json().set("json-key", Path.root_path(), "foo") + assert r.json().strappend("json-key", "bar") == 6 + assert "foobar" == r.json().get("json-key", Path.root_path()) + + # Test multi + r.json().set("doc1", Path.root_path(), {"a": "foo", "nested1": {"a": "hello"}, "nested2": {"a": 31}, }) + assert r.json().strappend("doc1", "bar", "$..a") == [6, 8, None] + assert r.json().get("doc1") == {"a": "foobar", "nested1": {"a": "hellobar"}, "nested2": {"a": 31}, } + + # Test single + assert r.json().strappend("doc1", "baz", "$.nested1.a", ) == [11] + assert r.json().get("doc1") == {"a": "foobar", "nested1": {"a": "hellobarbaz"}, "nested2": {"a": 31}, } + + # Test missing key + with pytest.raises(redis.exceptions.ResponseError): + r.json().strappend("non_existing_doc", "$..a", "err") + + # Test multi + r.json().set("doc2", Path.root_path(), {"a": "foo", "nested1": {"a": "hello"}, "nested2": {"a": "hi"}, }) + assert r.json().strappend("doc2", "bar", "$.*.a") == [8, 5] + assert r.json().get("doc2") == {"a": "foo", "nested1": {"a": "hellobar"}, "nested2": {"a": "hibar"}, } + + # Test missing path + r.json().set("doc1", Path.root_path(), {"a": "foo", "nested1": {"a": "hello"}, "nested2": {"a": 31}, }) + with pytest.raises(redis.exceptions.ResponseError): + r.json().strappend("doc1", "add", "piu") + + # Test raw command with no arguments + with pytest.raises(redis.ResponseError) as e: + raw_command(r, 'json.strappend', '') + + +@pytest.mark.decode_responses(True) +def test_decode_null(r: redis.Redis): + assert r.json().get("abc") is None + + +def test_decode_response_disabaled_null(r: redis.Redis): + assert r.json().get("abc") is None + + +def test_json_get_jset(r: redis.Redis) -> None: + assert r.json().set("foo", Path.root_path(), "bar", ) == 1 + assert "bar" == r.json().get("foo") + assert r.json().get("baz") is None + assert 1 == r.json().delete("foo") + assert r.exists("foo") == 0 + + +def test_nonascii_setgetdelete(r: redis.Redis) -> None: + assert r.json().set("not-ascii", Path.root_path(), "hyvää-élève", ) + assert "hyvää-élève" == r.json().get("not-ascii", no_escape=True, ) + assert 1 == r.json().delete("not-ascii") + assert r.exists("not-ascii") == 0 + + +def test_json_setbinarykey(r: redis.Redis) -> None: + data = {"hello": "world", b"some": "value"} + + with pytest.raises(TypeError): + r.json().set("some-key", Path.root_path(), data) + + assert r.json().set("some-key", Path.root_path(), data, decode_keys=True) + + +def test_set_file(r: redis.Redis) -> None: + # Standard Library Imports + import json + import tempfile + + obj = {"hello": "world"} + jsonfile = tempfile.NamedTemporaryFile(suffix=".json") + with open(jsonfile.name, "w+") as fp: + fp.write(json.dumps(obj)) + + no_json_file = tempfile.NamedTemporaryFile() + no_json_file.write(b"Hello World") + + assert r.json().set_file("test", Path.root_path(), jsonfile.name) + assert r.json().get("test") == obj + with pytest.raises(json.JSONDecodeError): + r.json().set_file("test2", Path.root_path(), no_json_file.name) + + +def test_set_path(r: redis.Redis) -> None: + # Standard Library Imports + import json + import tempfile + + root = tempfile.mkdtemp() + sub = tempfile.mkdtemp(dir=root) + jsonfile = tempfile.mktemp(suffix=".json", dir=sub) + no_json_file = tempfile.mktemp(dir=root) + + with open(jsonfile, "w+") as fp: + fp.write(json.dumps({"hello": "world"})) + with open(no_json_file, "a+") as fp: + fp.write("hello") + + result = {jsonfile: True, no_json_file: False} + assert r.json().set_path(Path.root_path(), root) == result + assert r.json().get(jsonfile.rsplit(".")[0]) == {"hello": "world"} + + +def test_type(r: redis.Redis) -> None: + r.json().set("1", Path.root_path(), 1, ) + + assert r.json().type("1", Path.root_path(), ) == b"integer" + assert r.json().type("1") == b"integer" + + meta_data = {"object": {}, "array": [], "string": "str", "integer": 42, "number": 1.2, "boolean": False, + "null": None, } + data = {k: {'a': meta_data[k]} for k in meta_data} + r.json().set("doc1", "$", data) + # Test multi + assert r.json().type("doc1", "$..a") == [k.encode() for k in meta_data.keys()] + + # Test single + assert r.json().type("doc1", f"$.integer.a") == [b'integer'] + assert r.json().type("doc1") == b'object' + + # Test missing key + assert r.json().type("non_existing_doc", "..a") is None + + +def test_objlen(r: redis.Redis) -> None: + # Test missing key, and path + with pytest.raises(redis.ResponseError): + r.json().objlen("non_existing_doc", "$..a") + + obj = {"foo": "bar", "baz": "qaz"} + + r.json().set("obj", Path.root_path(), obj, ) + assert len(obj) == r.json().objlen("obj", Path.root_path(), ) + + r.json().set("obj", Path.root_path(), obj) + assert len(obj) == r.json().objlen("obj") + r.json().set("doc1", "$", { + "a": ["foo"], + "nested1": {"a": {"foo": 10, "bar": 20}}, + "nested2": {"a": {"baz": 50}}, + }) + # Test multi + assert r.json().objlen("doc1", "$..a") == [None, 2, 1] + # Test single + assert r.json().objlen("doc1", "$.nested1.a") == [2] + + assert r.json().objlen("doc1", "$.nowhere") == [] + + # Test legacy + assert r.json().objlen("doc1", ".*.a") == 2 + + # Test single + assert r.json().objlen("doc1", ".nested2.a") == 1 + + # Test missing key + assert r.json().objlen("non_existing_doc", "..a") is None + + # Test missing path + # with pytest.raises(exceptions.ResponseError): + r.json().objlen("doc1", ".nowhere") + + +def test_objkeys(r: redis.Redis): + obj = {"foo": "bar", "baz": "qaz"} + r.json().set("obj", Path.root_path(), obj) + keys = r.json().objkeys("obj", Path.root_path()) + keys.sort() + exp = list(obj.keys()) + exp.sort() + assert exp == keys + + r.json().set("obj", Path.root_path(), obj) + assert r.json().objkeys("obj") == list(obj.keys()) + + assert r.json().objkeys("fakekey") is None + + r.json().set( + "doc1", + "$", + { + "nested1": {"a": {"foo": 10, "bar": 20}}, + "a": ["foo"], + "nested2": {"a": {"baz": 50}}, + }, + ) + + # Test single + assert r.json().objkeys("doc1", "$.nested1.a") == [[b"foo", b"bar"]] + + # Test legacy + assert r.json().objkeys("doc1", ".*.a") == ["foo", "bar"] + # Test single + assert r.json().objkeys("doc1", ".nested2.a") == ["baz"] + + # Test missing key + assert r.json().objkeys("non_existing_doc", "..a") is None + + # Test non existing doc + with pytest.raises(redis.ResponseError): + assert r.json().objkeys("non_existing_doc", "$..a") == [] + + assert r.json().objkeys("doc1", "$..nowhere") == [] + + +def test_numincrby(r: redis.Redis) -> None: + r.json().set("num", Path.root_path(), 1) + + assert 2 == r.json().numincrby("num", Path.root_path(), 1) + assert 2.5 == r.json().numincrby("num", Path.root_path(), 0.5) + assert 1.25 == r.json().numincrby("num", Path.root_path(), -1.25) + # Test NUMINCRBY + r.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) + # Test multi + assert r.json().numincrby("doc1", "$..a", 2) == [None, 4, 7.0, None] + + assert r.json().numincrby("doc1", "$..a", 2.5) == [None, 6.5, 9.5, None] + # Test single + assert r.json().numincrby("doc1", "$.b[1].a", 2) == [11.5] + + assert r.json().numincrby("doc1", "$.b[2].a", 2) == [None] + assert r.json().numincrby("doc1", "$.b[1].a", 3.5) == [15.0] + + +def test_nummultby(r: redis.Redis) -> None: + r.json().set("num", Path.root_path(), 1) + + with pytest.deprecated_call(): + assert r.json().nummultby("num", Path.root_path(), 2) == 2 + assert r.json().nummultby("num", Path.root_path(), 2.5) == 5 + assert r.json().nummultby("num", Path.root_path(), 0.5) == 2.5 + + r.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) + + # test list + with pytest.deprecated_call(): + assert r.json().nummultby("doc1", "$..a", 2) == [None, 4, 10, None] + assert r.json().nummultby("doc1", "$..a", 2.5) == [None, 10.0, 25.0, None] + + # Test single + with pytest.deprecated_call(): + assert r.json().nummultby("doc1", "$.b[1].a", 2) == [50.0] + assert r.json().nummultby("doc1", "$.b[2].a", 2) == [None] + assert r.json().nummultby("doc1", "$.b[1].a", 3) == [150.0] + + # test missing keys + with pytest.raises(redis.ResponseError): + r.json().numincrby("non_existing_doc", "$..a", 2) + r.json().nummultby("non_existing_doc", "$..a", 2) + + # Test legacy NUMINCRBY + r.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) + assert r.json().numincrby("doc1", ".b[0].a", 3) == 5 + + # Test legacy NUMMULTBY + r.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}) + + with pytest.deprecated_call(): + assert r.json().nummultby("doc1", ".b[0].a", 3) == 6 diff -Nru fakeredis-2.4.0/test/test_mixins/test_bitmap_commands.py fakeredis-2.10.3/test/test_mixins/test_bitmap_commands.py --- fakeredis-2.4.0/test/test_mixins/test_bitmap_commands.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_mixins/test_bitmap_commands.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,210 @@ +import pytest +import redis +import redis.client + +from test.testtools import raw_command + + +def test_getbit(r): + r.setbit('foo', 3, 1) + assert r.getbit('foo', 0) == 0 + assert r.getbit('foo', 1) == 0 + assert r.getbit('foo', 2) == 0 + assert r.getbit('foo', 3) == 1 + assert r.getbit('foo', 4) == 0 + assert r.getbit('foo', 100) == 0 + + +def test_getbit_wrong_type(r): + r.rpush('foo', b'x') + with pytest.raises(redis.ResponseError): + r.getbit('foo', 1) + + +def test_multiple_bits_set(r): + r.setbit('foo', 1, 1) + r.setbit('foo', 3, 1) + r.setbit('foo', 5, 1) + + assert r.getbit('foo', 0) == 0 + assert r.getbit('foo', 1) == 1 + assert r.getbit('foo', 2) == 0 + assert r.getbit('foo', 3) == 1 + assert r.getbit('foo', 4) == 0 + assert r.getbit('foo', 5) == 1 + assert r.getbit('foo', 6) == 0 + + +def test_unset_bits(r): + r.setbit('foo', 1, 1) + r.setbit('foo', 2, 0) + r.setbit('foo', 3, 1) + assert r.getbit('foo', 1) == 1 + r.setbit('foo', 1, 0) + assert r.getbit('foo', 1) == 0 + r.setbit('foo', 3, 0) + assert r.getbit('foo', 3) == 0 + + +def test_get_set_bits(r): + # set bit 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) + # set bit 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) + + +def test_setbits_and_getkeys(r): + # The bit operations and the get commands + # should play nicely with each other. + r.setbit('foo', 1, 1) + assert r.get('foo') == b'@' + r.setbit('foo', 2, 1) + assert r.get('foo') == b'`' + r.setbit('foo', 3, 1) + assert r.get('foo') == b'p' + r.setbit('foo', 9, 1) + assert r.get('foo') == b'p@' + r.setbit('foo', 54, 1) + assert r.get('foo') == b'p@\x00\x00\x00\x00\x02' + + +def test_setbit_wrong_type(r): + r.rpush('foo', b'x') + with pytest.raises(redis.ResponseError): + r.setbit('foo', 0, 1) + + +def test_setbit_expiry(r): + r.set('foo', b'0x00', ex=10) + r.setbit('foo', 1, 1) + assert r.ttl('foo') > 0 + + +def test_bitcount(r): + r.delete('foo') + assert r.bitcount('foo') == 0 + r.setbit('foo', 1, 1) + assert r.bitcount('foo') == 1 + r.setbit('foo', 8, 1) + assert r.bitcount('foo') == 2 + assert r.bitcount('foo', 1, 1) == 1 + r.setbit('foo', 57, 1) + assert r.bitcount('foo') == 3 + r.set('foo', ' ') + assert r.bitcount('foo') == 1 + r.set('key', 'foobar') + with pytest.raises(redis.ResponseError): + raw_command(r, 'bitcount', 'key', '1', '2', 'dsd') + assert r.bitcount('key') == 26 + assert r.bitcount('key', start=0, end=0) == 4 + assert r.bitcount('key', start=1, end=1) == 6 + + +@pytest.mark.max_server('6.2.7') +def test_bitcount_mode_redis6(r): + r.set('key', 'foobar') + with pytest.raises(redis.ResponseError): + r.bitcount('key', start=1, end=1, mode='byte') + with pytest.raises(redis.ResponseError): + r.bitcount('key', start=1, end=1, mode='bit') + with pytest.raises(redis.ResponseError): + raw_command(r, 'bitcount', 'key', '1', '2', 'dsd', 'cd') + + +@pytest.mark.min_server('7') +def test_bitcount_mode_redis7(r: redis.Redis): + r.set('key', 'foobar') + assert r.bitcount('key', start=1, end=1, mode='byte') == 6 + assert r.bitcount('key', start=5, end=30, mode='bit') == 17 + with pytest.raises(redis.ResponseError): + r.bitcount('key', start=5, end=30, mode='dscd') + with pytest.raises(redis.ResponseError): + raw_command(r, 'bitcount', 'key', '1', '2', 'dsd', 'cd') + + +def test_bitcount_wrong_type(r): + r.rpush('foo', b'x') + with pytest.raises(redis.ResponseError): + r.bitcount('foo') + + +def test_bitop(r): + r.set('key1', 'foobar') + r.set('key2', 'abcdef') + + assert r.bitop('and', 'dest', 'key1', 'key2') == 6 + assert r.get('dest') == b'`bc`ab' + + assert r.bitop('not', 'dest1', 'key1') == 6 + assert r.get('dest1') == b'\x99\x90\x90\x9d\x9e\x8d' + + assert r.bitop('or', 'dest-or', 'key1', 'key2') == 6 + assert r.get('dest-or') == b'goofev' + + assert r.bitop('xor', 'dest-xor', 'key1', 'key2') == 6 + assert r.get('dest-xor') == b'\x07\r\x0c\x06\x04\x14' + + +def test_bitop_errors(r): + r.set('key1', 'foobar') + r.set('key2', 'abcdef') + r.sadd('key-set', 'member1') + with pytest.raises(redis.ResponseError): + r.bitop('not', 'dest', 'key1', 'key2') + with pytest.raises(redis.ResponseError): + r.bitop('badop', 'dest', 'key1', 'key2') + with pytest.raises(redis.ResponseError): + r.bitop('and', 'dest', 'key1', 'key-set') + with pytest.raises(redis.ResponseError): + r.bitop('and', 'dest') + + +def test_bitpos(r: redis.Redis): + 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") + assert r.bitpos(key, 1, 0) == 8 + assert r.bitpos(key, 1, 1) == 8 + r.set(key, b"\x00\x00\x00") + assert r.bitpos(key, 1) == -1 + r.set(key, b"\xff\xf0\x00") + + +@pytest.mark.min_server('7') +def test_bitops_mode_redis7(r: redis.Redis): + key = "key:bitpos" + r.set(key, b"\xff\xf0\x00") + assert r.bitpos(key, 0, 8, -1, 'bit') == 12 + assert r.bitpos(key, 1, 8, -1, 'bit') == 8 + with pytest.raises(redis.ResponseError): + assert r.bitpos(key, 0, 8, -1, 'bad_mode') == 12 + + +@pytest.mark.max_server('6.2.7') +def test_bitops_mode_redis6(r: redis.Redis): + key = "key:bitpos" + r.set(key, b"\xff\xf0\x00") + with pytest.raises(redis.ResponseError): + assert r.bitpos(key, 0, 8, -1, 'bit') == 12 + + +def test_bitpos_wrong_arguments(r: redis.Redis): + key = "key:bitpos:wrong:args" + r.set(key, b"\xff\xf0\x00") + with pytest.raises(redis.ResponseError): + raw_command(r, 'bitpos', key, '7') + with pytest.raises(redis.ResponseError): + raw_command(r, 'bitpos', key, 1, '6', '5', 'BYTE', '6') + with pytest.raises(redis.ResponseError): + raw_command(r, 'bitpos', key) diff -Nru fakeredis-2.4.0/test/test_mixins/test_generic_commands.py fakeredis-2.10.3/test/test_mixins/test_generic_commands.py --- fakeredis-2.4.0/test/test_mixins/test_generic_commands.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_mixins/test_generic_commands.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,754 @@ +import pytest +import redis +from datetime import datetime, timedelta +from redis.exceptions import ResponseError +from time import sleep, time + +from test.testtools import raw_command + + +def key_val_dict(size=100): + return {b'key:' + bytes([i]): b'val:' + bytes([i]) + for i in range(size)} + + +@pytest.mark.slow +def test_expireat_should_expire_key_by_datetime(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.expireat('foo', datetime.now() + timedelta(seconds=1)) + sleep(1.5) + assert r.get('foo') is None + assert r.expireat('bar', datetime.now()) is False + + +@pytest.mark.slow +def test_expireat_should_expire_key_by_timestamp(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.expireat('foo', int(time() + 1)) + sleep(1.5) + assert r.get('foo') is None + assert r.expire('bar', 1) is False + + +def test_expireat_should_return_true_for_existing_key(r): + r.set('foo', 'bar') + assert r.expireat('foo', int(time() + 1)) is True + + +def test_expireat_should_return_false_for_missing_key(r): + assert r.expireat('missing', int(time() + 1)) is False + + +def test_del_operator(r): + r['foo'] = 'bar' + del r['foo'] + assert r.get('foo') is None + + +def test_expire_should_not_handle_floating_point_values(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError, match='value is not an integer or out of range'): + r.expire('something_new', 1.2) + r.pexpire('something_new', 1000.2) + r.expire('some_unused_key', 1.2) + r.pexpire('some_unused_key', 1000.2) + + +def test_ttl_should_return_minus_one_for_non_expiring_key(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + assert r.ttl('foo') == -1 + + +def test_scan(r): + # Set up the data + for ix in range(20): + k = 'scan-test:%s' % ix + v = 'result:%s' % ix + r.set(k, v) + expected = r.keys() + assert len(expected) == 20 # Ensure we know what we're testing + + # Test that we page through the results and get everything out + results = [] + cursor = '0' + while cursor != 0: + cursor, data = r.scan(cursor, count=6) + results.extend(data) + assert set(expected) == set(results) + + # Now test that the MATCH functionality works + results = [] + cursor = '0' + while cursor != 0: + cursor, data = r.scan(cursor, match='*7', count=100) + results.extend(data) + assert b'scan-test:7' in results + assert b'scan-test:17' in results + assert len(results) == 2 + + # Test the match on iterator + results = [r for r in r.scan_iter(match='*7')] + assert b'scan-test:7' in results + assert b'scan-test:17' in results + assert len(results) == 2 + + +def test_sort_range_offset_range(r): + r.rpush('foo', '2') + r.rpush('foo', '1') + r.rpush('foo', '4') + r.rpush('foo', '3') + + assert r.sort('foo', start=0, num=2) == [b'1', b'2'] + + +def test_sort_range_offset_range_and_desc(r): + r.rpush('foo', '2') + r.rpush('foo', '1') + r.rpush('foo', '4') + r.rpush('foo', '3') + + assert r.sort("foo", start=0, num=1, desc=True) == [b"4"] + + +def test_sort_range_offset_norange(r): + with pytest.raises(redis.RedisError): + r.sort('foo', start=1) + + +def test_sort_range_with_large_range(r): + r.rpush('foo', '2') + r.rpush('foo', '1') + r.rpush('foo', '4') + r.rpush('foo', '3') + # num=20 even though len(foo) is 4. + assert r.sort('foo', start=1, num=20) == [b'2', b'3', b'4'] + + +def test_sort_descending(r): + r.rpush('foo', '1') + r.rpush('foo', '2') + r.rpush('foo', '3') + assert r.sort('foo', desc=True) == [b'3', b'2', b'1'] + + +def test_sort_alpha(r): + r.rpush('foo', '2a') + r.rpush('foo', '1b') + r.rpush('foo', '2b') + r.rpush('foo', '1a') + + assert r.sort('foo', alpha=True) == [b'1a', b'1b', b'2a', b'2b'] + + +def test_sort_foo(r): + r.rpush('foo', '2a') + r.rpush('foo', '1b') + r.rpush('foo', '2b') + r.rpush('foo', '1a') + with pytest.raises(redis.ResponseError): + r.sort('foo', alpha=False) + + +def test_sort_empty(r): + assert r.sort('foo') == [] + + +def test_sort_wrong_type(r): + r.set('string', '3') + with pytest.raises(redis.ResponseError): + r.sort('string') + + +def test_sort_with_store_option(r): + r.rpush('foo', '2') + r.rpush('foo', '1') + r.rpush('foo', '4') + r.rpush('foo', '3') + + assert r.sort('foo', store='bar') == 4 + assert r.lrange('bar', 0, -1) == [b'1', b'2', b'3', b'4'] + + +def test_sort_with_by_and_get_option(r): + r.rpush('foo', '2') + r.rpush('foo', '1') + r.rpush('foo', '4') + r.rpush('foo', '3') + + r['weight_1'] = '4' + r['weight_2'] = '3' + r['weight_3'] = '2' + r['weight_4'] = '1' + + r['data_1'] = 'one' + r['data_2'] = 'two' + r['data_3'] = 'three' + r['data_4'] = 'four' + + assert ( + r.sort('foo', by='weight_*', get='data_*') + == [b'four', b'three', b'two', b'one'] + ) + assert r.sort('foo', by='weight_*', get='#') == [b'4', b'3', b'2', b'1'] + assert ( + r.sort('foo', by='weight_*', get=('data_*', '#')) + == [b'four', b'4', b'three', b'3', b'two', b'2', b'one', b'1'] + ) + assert r.sort('foo', by='weight_*', get='data_1') == [None, None, None, None] + # Test sort with different parameters order + assert ( + raw_command(r, 'sort', 'foo', 'get', 'data_*', 'by', 'weight_*', 'get', '#') + == [b'four', b'4', b'three', b'3', b'two', b'2', b'one', b'1'] + ) + + +def test_sort_with_hash(r): + r.rpush('foo', 'middle') + r.rpush('foo', 'eldest') + r.rpush('foo', 'youngest') + r.hset('record_youngest', 'age', 1) + r.hset('record_youngest', 'name', 'baby') + + r.hset('record_middle', 'age', 10) + r.hset('record_middle', 'name', 'teen') + + r.hset('record_eldest', 'age', 20) + r.hset('record_eldest', 'name', 'adult') + + assert r.sort('foo', by='record_*->age') == [b'youngest', b'middle', b'eldest'] + assert ( + r.sort('foo', by='record_*->age', get='record_*->name') + == [b'baby', b'teen', b'adult'] + ) + + +def test_sort_with_set(r): + r.sadd('foo', '3') + r.sadd('foo', '1') + r.sadd('foo', '2') + assert r.sort('foo') == [b'1', b'2', b'3'] + + +def test_ttl_should_return_minus_two_for_non_existent_key(r): + assert r.get('foo') is None + assert r.ttl('foo') == -2 + + +def test_type(r): + r.set('string_key', "value") + r.lpush("list_key", "value") + r.sadd("set_key", "value") + r.zadd("zset_key", {"value": 1}) + r.hset('hset_key', 'key', 'value') + + assert r.type('string_key') == b'string' + assert r.type('list_key') == b'list' + assert r.type('set_key') == b'set' + assert r.type('zset_key') == b'zset' + assert r.type('hset_key') == b'hash' + assert r.type('none_key') == b'none' + + +def test_unlink(r): + r.set('foo', 'bar') + r.unlink('foo') + assert r.get('foo') is None + + +def test_dump_missing(r): + assert r.dump('foo') is None + + +def test_dump_restore(r): + r.set('foo', 'bar') + dump = r.dump('foo') + r.restore('baz', 0, dump) + assert r.get('baz') == b'bar' + assert r.ttl('baz') == -1 + + +def test_dump_restore_ttl(r): + r.set('foo', 'bar') + dump = r.dump('foo') + r.restore('baz', 2000, dump) + assert r.get('baz') == b'bar' + assert 1000 <= r.pttl('baz') <= 2000 + + +def test_dump_restore_replace(r): + r.set('foo', 'bar') + dump = r.dump('foo') + r.set('foo', 'baz') + r.restore('foo', 0, dump, replace=True) + assert r.get('foo') == b'bar' + + +def test_restore_exists(r): + r.set('foo', 'bar') + dump = r.dump('foo') + with pytest.raises(redis.exceptions.ResponseError): + r.restore('foo', 0, dump) + + +def test_restore_invalid_dump(r): + r.set('foo', 'bar') + dump = r.dump('foo') + with pytest.raises(redis.exceptions.ResponseError): + r.restore('baz', 0, dump[:-1]) + + +def test_restore_invalid_ttl(r): + r.set('foo', 'bar') + dump = r.dump('foo') + with pytest.raises(redis.exceptions.ResponseError): + r.restore('baz', -1, dump) + + +def test_set_then_get(r): + assert r.set('foo', 'bar') is True + assert r.get('foo') == b'bar' + + +def test_exists(r): + assert 'foo' not in r + r.set('foo', 'bar') + assert 'foo' in r + + +@pytest.mark.slow +def test_expire_should_expire_key(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.expire('foo', 1) + sleep(1.5) + assert r.get('foo') is None + assert r.expire('bar', 1) is False + + +def test_expire_should_throw_error(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + with pytest.raises(ResponseError): + r.expire('foo', 1, nx=True, xx=True) + with pytest.raises(ResponseError): + r.expire('foo', 1, nx=True, gt=True) + with pytest.raises(ResponseError): + r.expire('foo', 1, nx=True, lt=True) + with pytest.raises(ResponseError): + r.expire('foo', 1, gt=True, lt=True) + + +@pytest.mark.max_server('7') +def test_expire_extra_params_return_error(r): + with pytest.raises(redis.exceptions.ResponseError): + r.expire('foo', 1, nx=True) + + +def test_expire_should_return_true_for_existing_key(r): + r.set('foo', 'bar') + assert r.expire('foo', 1) is True + + +def test_expire_should_return_false_for_missing_key(r): + assert r.expire('missing', 1) is False + + +@pytest.mark.slow +def test_expire_should_expire_key_using_timedelta(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.expire('foo', timedelta(seconds=1)) + sleep(1.5) + assert r.get('foo') is None + assert r.expire('bar', 1) is False + + +@pytest.mark.slow +def test_expire_should_expire_immediately_with_millisecond_timedelta(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.expire('foo', timedelta(milliseconds=750)) + assert r.get('foo') is None + assert r.expire('bar', 1) is False + + +def test_watch_expire(r): + """EXPIRE should mark a key as changed for WATCH.""" + r.set('foo', 'bar') + with r.pipeline() as p: + p.watch('foo') + r.expire('foo', 10000) + p.multi() + p.get('foo') + with pytest.raises(redis.exceptions.WatchError): + p.execute() + + +@pytest.mark.slow +def test_pexpire_should_expire_key(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.pexpire('foo', 150) + sleep(0.2) + assert r.get('foo') is None + assert r.pexpire('bar', 1) == 0 + + +def test_pexpire_should_return_truthy_for_existing_key(r): + r.set('foo', 'bar') + assert r.pexpire('foo', 1) + + +def test_pexpire_should_return_falsey_for_missing_key(r): + assert not r.pexpire('missing', 1) + + +@pytest.mark.slow +def test_pexpire_should_expire_key_using_timedelta(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.pexpire('foo', timedelta(milliseconds=750)) + sleep(0.5) + assert r.get('foo') == b'bar' + sleep(0.5) + assert r.get('foo') is None + assert r.pexpire('bar', 1) == 0 + + +@pytest.mark.slow +def test_pexpireat_should_expire_key_by_datetime(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.pexpireat('foo', datetime.now() + timedelta(milliseconds=150)) + sleep(0.2) + assert r.get('foo') is None + assert r.pexpireat('bar', datetime.now()) == 0 + + +@pytest.mark.slow +def test_pexpireat_should_expire_key_by_timestamp(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + r.pexpireat('foo', int(time() * 1000 + 150)) + sleep(0.2) + assert r.get('foo') is None + assert r.expire('bar', 1) is False + + +def test_pexpireat_should_return_true_for_existing_key(r): + r.set('foo', 'bar') + assert r.pexpireat('foo', int(time() * 1000 + 150)) + + +def test_pexpireat_should_return_false_for_missing_key(r): + assert not r.pexpireat('missing', int(time() * 1000 + 150)) + + +def test_pttl_should_return_minus_one_for_non_expiring_key(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + assert r.pttl('foo') == -1 + + +def test_pttl_should_return_minus_two_for_non_existent_key(r): + assert r.get('foo') is None + assert r.pttl('foo') == -2 + + +def test_randomkey_returns_none_on_empty_db(r): + assert r.randomkey() is None + + +def test_randomkey_returns_existing_key(r): + r.set("foo", 1) + r.set("bar", 2) + r.set("baz", 3) + assert r.randomkey().decode() in ("foo", "bar", "baz") + + +def test_persist(r): + r.set('foo', 'bar', ex=20) + assert r.persist('foo') == 1 + assert r.ttl('foo') == -1 + assert r.persist('foo') == 0 + + +def test_watch_persist(r): + """PERSIST should mark a variable as changed.""" + r.set('foo', 'bar', ex=10000) + with r.pipeline() as p: + p.watch('foo') + r.persist('foo') + p.multi() + p.get('foo') + with pytest.raises(redis.exceptions.WatchError): + p.execute() + + +def test_set_existing_key_persists(r): + r.set('foo', 'bar', ex=20) + r.set('foo', 'foo') + assert r.ttl('foo') == -1 + + +def test_set_non_str_keys(r): + assert r.set(2, 'bar') is True + assert r.get(2) == b'bar' + assert r.get('2') == b'bar' + + +def test_getset_not_exist(r): + val = r.getset('foo', 'bar') + assert val is None + assert r.get('foo') == b'bar' + + +def test_get_float_type(r): # Test for issue #58 + r.set('key', 123) + assert r.get('key') == b'123' + r.incr('key') + assert r.get('key') == b'124' + + +def test_set_float_value(r): + x = 1.23456789123456789 + r.set('foo', x) + assert float(r.get('foo')) == x + + +@pytest.mark.min_server('7') +def test_expire_should_not_expire__when_no_expire_is_set(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + assert r.expire('foo', 1, xx=True) == 0 + + +@pytest.mark.min_server('7') +def test_expire_should_not_expire__when_expire_is_set(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + assert r.expire('foo', 1, nx=True) == 1 + assert r.expire('foo', 2, nx=True) == 0 + + +@pytest.mark.min_server('7') +def test_expire_should_expire__when_expire_is_greater(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + assert r.expire('foo', 100) == 1 + assert r.get('foo') == b'bar' + assert r.expire('foo', 200, gt=True) == 1 + + +@pytest.mark.min_server('7') +def test_expire_should_expire__when_expire_is_lessthan(r): + r.set('foo', 'bar') + assert r.get('foo') == b'bar' + assert r.expire('foo', 20) == 1 + assert r.expire('foo', 10, lt=True) == 1 + + +def test_rename(r): + r.set('foo', 'unique value') + assert r.rename('foo', 'bar') + assert r.get('foo') is None + assert r.get('bar') == b'unique value' + + +def test_rename_nonexistent_key(r): + with pytest.raises(redis.ResponseError): + r.rename('foo', 'bar') + + +def test_renamenx_doesnt_exist(r): + r.set('foo', 'unique value') + assert r.renamenx('foo', 'bar') + assert r.get('foo') is None + assert r.get('bar') == b'unique value' + + +def test_rename_does_exist(r): + r.set('foo', 'unique value') + r.set('bar', 'unique value2') + assert not r.renamenx('foo', 'bar') + assert r.get('foo') == b'unique value' + assert r.get('bar') == b'unique value2' + + +def test_rename_expiry(r): + r.set('foo', 'value1', ex=10) + r.set('bar', 'value2') + r.rename('foo', 'bar') + assert r.ttl('bar') > 0 + + +def test_keys(r): + r.set('', 'empty') + r.set('abc\n', '') + r.set('abc\\', '') + r.set('abcde', '') + r.set(b'\xfe\xcd', '') + assert sorted(r.keys()) == [b'', b'abc\n', b'abc\\', b'abcde', b'\xfe\xcd'] + assert r.keys('??') == [b'\xfe\xcd'] + # empty pattern not the same as no pattern + assert r.keys('') == [b''] + # ? must match \n + assert sorted(r.keys('abc?')) == [b'abc\n', b'abc\\'] + # must be anchored at both ends + assert r.keys('abc') == [] + assert r.keys('bcd') == [] + # wildcard test + assert r.keys('a*de') == [b'abcde'] + # positive groups + assert sorted(r.keys('abc[d\n]*')) == [b'abc\n', b'abcde'] + assert r.keys('abc[c-e]?') == [b'abcde'] + assert r.keys('abc[e-c]?') == [b'abcde'] + assert r.keys('abc[e-e]?') == [] + assert r.keys('abcd[ef') == [b'abcde'] + assert r.keys('abcd[]') == [] + # negative groups + assert r.keys('abc[^d\\\\]*') == [b'abc\n'] + assert r.keys('abc[^]e') == [b'abcde'] + # escaping + assert r.keys(r'abc\?e') == [] + assert r.keys(r'abc\de') == [b'abcde'] + assert r.keys(r'abc[\d]e') == [b'abcde'] + # some escaping cases that redis handles strangely + assert r.keys('abc\\') == [b'abc\\'] + assert r.keys(r'abc[\c-e]e') == [] + assert r.keys(r'abc[c-\e]e') == [] + + +def test_contains(r): + assert not r.exists('foo') + r.set('foo', 'bar') + assert r.exists('foo') + + +def test_delete(r): + r['foo'] = 'bar' + assert r.delete('foo') == 1 + assert r.get('foo') is None + + +@pytest.mark.slow +def test_delete_expire(r): + r.set("foo", "bar", ex=1) + r.delete("foo") + r.set("foo", "bar") + sleep(2) + assert r.get("foo") == b'bar' + + +def test_delete_multiple(r): + r['one'] = 'one' + r['two'] = 'two' + r['three'] = 'three' + # Since redis>=2.7.6 returns number of deleted items. + assert r.delete('one', 'two') == 2 + assert r.get('one') is None + assert r.get('two') is None + assert r.get('three') == b'three' + assert r.delete('one', 'two') == 0 + # If any keys are deleted, True is returned. + assert r.delete('two', 'three', 'three') == 1 + assert r.get('three') is None + + +def test_delete_nonexistent_key(r): + assert r.delete('foo') == 0 + + +def test_scan_single(r): + r.set('foo1', 'bar1') + assert r.scan(match="foo*") == (0, [b'foo1']) + + +def test_scan_iter_single_page(r): + r.set('foo1', 'bar1') + r.set('foo2', 'bar2') + assert set(r.scan_iter(match="foo*")) == {b'foo1', b'foo2'} + assert set(r.scan_iter()) == {b'foo1', b'foo2'} + assert set(r.scan_iter(match="")) == set() + assert set(r.scan_iter(match="foo1", _type="string")) == {b'foo1', } + + +def test_scan_iter_multiple_pages(r): + all_keys = key_val_dict(size=100) + assert all(r.set(k, v) for k, v in all_keys.items()) + assert set(r.scan_iter()) == set(all_keys) + + +def test_scan_iter_multiple_pages_with_match(r): + all_keys = key_val_dict(size=100) + assert all(r.set(k, v) for k, v in all_keys.items()) + # Now add a few keys that don't match the key: pattern. + r.set('otherkey', 'foo') + r.set('andanother', 'bar') + actual = set(r.scan_iter(match='key:*')) + assert actual == set(all_keys) + + +def test_scan_multiple_pages_with_count_arg(r): + all_keys = key_val_dict(size=100) + assert all(r.set(k, v) for k, v in all_keys.items()) + assert set(r.scan_iter(count=1000)) == set(all_keys) + + +def test_scan_all_in_single_call(r): + all_keys = key_val_dict(size=100) + assert all(r.set(k, v) for k, v in all_keys.items()) + # Specify way more than the 100 keys we've added. + actual = r.scan(count=1000) + assert set(actual[1]) == set(all_keys) + assert actual[0] == 0 + + +@pytest.mark.slow +def test_scan_expired_key(r): + r.set('expiringkey', 'value') + r.pexpire('expiringkey', 1) + sleep(1) + assert r.scan()[1] == [] + + +def test_basic_sort(r): + r.rpush('foo', '2') + r.rpush('foo', '1') + r.rpush('foo', '3') + + assert r.sort('foo') == [b'1', b'2', b'3'] + assert raw_command(r, 'sort', 'foo', 'asc') == [b'1', b'2', b'3'] + + +def test_key_patterns(r): + r.mset({'one': 1, 'two': 2, 'three': 3, 'four': 4}) + assert sorted(r.keys('*o*')) == [b'four', b'one', b'two'] + assert r.keys('t??') == [b'two'] + assert sorted(r.keys('*')) == [b'four', b'one', b'three', b'two'] + assert sorted(r.keys()) == [b'four', b'one', b'three', b'two'] + + +@pytest.mark.min_server('7') +def test_watch_when_setbit_does_not_change_value(r: redis.Redis): + r.set('foo', b'0') + + with r.pipeline() as p: + p.watch('foo') + assert r.setbit('foo', 0, 0) == 0 + assert p.multi() is None + assert p.execute() == [] + + +def test_from_hypothesis_redis7(r: redis.Redis): + r.set('foo', b'0') + assert r.setbit('foo', 0, 0) == 0 + assert r.append('foo', b'') == 1 + + r.set(b'', b'') + assert r.setbit(b'', 0, 0) == 0 + assert r.get(b'') == b'\x00' diff -Nru fakeredis-2.4.0/test/test_mixins/test_geo_commands.py fakeredis-2.10.3/test/test_mixins/test_geo_commands.py --- fakeredis-2.4.0/test/test_mixins/test_geo_commands.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_mixins/test_geo_commands.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,219 @@ +from typing import Dict, Any + +import pytest +import redis + +from test import testtools + + +def test_geoadd(r: redis.Redis): + 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") + 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"] + + with pytest.raises(redis.DataError): + r.geoadd("barcelona", (1, 2)) + with pytest.raises(redis.DataError): + r.geoadd("t", values, ch=True, nx=True, xx=True) + with pytest.raises(redis.ResponseError): + testtools.raw_command(r, "geoadd", "barcelona", "1", "2") + with pytest.raises(redis.ResponseError): + testtools.raw_command(r, "geoadd", "barcelona", "nx", "xx", *values, ) + + +def test_geoadd_xx(r: redis.Redis): + 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"] + + +def test_geoadd_ch(r: redis.Redis): + 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"] + + +def test_geohash(r: redis.Redis): + 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, + ] + + +def test_geopos(r: redis.Redis): + values = ((2.1909389952632, 41.433791470673, "place1") + + (2.1873744593677, 41.406342043777, "place2",)) + r.geoadd("barcelona", values) + # small errors may be introduced. + assert r.geopos("barcelona", "place1", "place4", "place2") == [ + pytest.approx((2.1909389952632, 41.433791470673), 0.00001), + None, + pytest.approx((2.1873744593677, 41.406342043777), 0.00001), + ] + + +def test_geodist(r: redis.Redis): + values = ((2.1909389952632, 41.433791470673, "place1") + + (2.1873744593677, 41.406342043777, "place2",)) + assert r.geoadd("barcelona", values) == 2 + assert r.geodist("barcelona", "place1", "place2") == pytest.approx(3067.4157, 0.0001) + + +def test_geodist_units(r: redis.Redis): + values = ((2.1909389952632, 41.433791470673, "place1") + + (2.1873744593677, 41.406342043777, "place2",)) + r.geoadd("barcelona", values) + assert r.geodist("barcelona", "place1", "place2", "km") == pytest.approx(3.0674, 0.0001) + assert r.geodist("barcelona", "place1", "place2", "mi") == pytest.approx(1.906, 0.0001) + assert r.geodist("barcelona", "place1", "place2", "ft") == pytest.approx(10063.6998, 0.0001) + with pytest.raises(redis.RedisError): + assert r.geodist("x", "y", "z", "inches") + + +def test_geodist_missing_one_member(r: redis.Redis): + values = (2.1909389952632, 41.433791470673, "place1") + r.geoadd("barcelona", values) + assert r.geodist("barcelona", "place1", "missing_member", "km") is None + + +@pytest.mark.parametrize( + "long,lat,radius,extra,expected", [ + (2.191, 41.433, 1000, {}, [b"place1"]), + (2.187, 41.406, 1000, {}, [b"place2"]), + (1, 2, 1000, {}, []), + (2.191, 41.433, 1, {"unit": "km"}, [b"place1"]), + (2.191, 41.433, 3000, {"count": 1}, [b"place1"]), + ]) +def test_georadius( + r: redis.Redis, long: float, lat: float, radius: float, + extra: Dict[str, Any], + expected): + values = ((2.1909389952632, 41.433791470673, "place1") + + (2.1873744593677, 41.406342043777, b"place2")) + r.geoadd("barcelona", values) + assert r.georadius("barcelona", long, lat, radius, **extra) == expected + + +@pytest.mark.parametrize( + "member,radius,extra,expected", [ + ('place1', 1000, {}, [b"place1"]), + ('place2', 1000, {}, [b"place2"]), + ('place1', 1, {"unit": "km"}, [b"place1"]), + ('place1', 3000, {"count": 1}, [b"place1"]), + ]) +def test_georadiusbymember( + r: redis.Redis, member: str, radius: float, + extra: Dict[str, Any], + expected): + values = ((2.1909389952632, 41.433791470673, "place1") + + (2.1873744593677, 41.406342043777, b"place2")) + r.geoadd("barcelona", values) + assert r.georadiusbymember("barcelona", member, radius, **extra) == expected + assert r.georadiusbymember("barcelona", member, radius, **extra, store_dist='extract') == len(expected) + assert r.zcard("extract") == len(expected) + + +def test_georadius_with(r: redis.Redis): + 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. + res = r.georadius("barcelona", 2.191, 41.433, 1, unit="km", withdist=True, withcoord=True, ) + assert res == [pytest.approx([b"place1", 0.0881, pytest.approx((2.1909, 41.4337), 0.0001)], 0.001)] + + res = r.georadius("barcelona", 2.191, 41.433, 1, unit="km", withdist=True, withcoord=True) + assert res == [pytest.approx([b"place1", 0.0881, pytest.approx((2.1909, 41.4337), 0.0001)], 0.001)] + + res = r.georadius("barcelona", 2.191, 41.433, 1, unit="km", withcoord=True) + assert res == [[b"place1", pytest.approx((2.1909, 41.4337), 0.0001)]] + + # test no values. + assert (r.georadius("barcelona", 2, 1, 1, unit="km", withdist=True, withcoord=True, ) == []) + + +def test_georadius_count(r: redis.Redis): + 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, store='barcelona') == 1 + assert r.georadius("barcelona", 2.191, 41.433, 3000, store_dist='extract') == 1 + assert r.zcard("extract") == 1 + res = r.georadius("barcelona", 2.191, 41.433, 3000, count=1, any=True) + assert (res == [b"place2"]) or res == [b'place1'] + + values = ((13.361389, 38.115556, "Palermo") + + (15.087269, 37.502669, "Catania",)) + + r.geoadd("Sicily", values) + assert testtools.raw_command( + r, "GEORADIUS", "Sicily", "15", "37", "200", "km", + "STOREDIST", "neardist", "STORE", "near") == 2 + assert r.zcard("near") == 2 + assert r.zcard("neardist") == 0 + + +def test_georadius_errors(r: redis.Redis): + values = ((13.361389, 38.115556, "Palermo") + + (15.087269, 37.502669, "Catania",)) + + r.geoadd("Sicily", values) + + with pytest.raises(redis.DataError): # Unsupported unit + r.georadius("barcelona", 2.191, 41.433, 3000, unit='dsf') + with pytest.raises(redis.ResponseError): # Unsupported unit + testtools.raw_command( + r, "GEORADIUS", "Sicily", "15", "37", "200", "ddds", + "STOREDIST", "neardist", "STORE", "near") + + bad_values = (13.361389, 38.115556, "Palermo", 15.087269, "Catania",) + with pytest.raises(redis.DataError): + r.geoadd('newgroup', bad_values) + with pytest.raises(redis.ResponseError): + testtools.raw_command(r, 'geoadd', 'newgroup', *bad_values) + + +def test_geosearch(r: redis.Redis): + values = ( + (2.1909389952632, 41.433791470673, "place1") + + (2.1873744593677, 41.406342043777, b"place2") + + (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"place2"] + # assert r.geosearch("barcelona", longitude=2.191, latitude=41.433, height=1000, width=1000) == [b"place1"] + assert set(r.geosearch("barcelona", member="place3", radius=100, unit="km")) == {b"place2", b"place1", b"place3", } + # test count + assert r.geosearch("barcelona", member="place3", radius=100, unit="km", count=2) == [b"place3", b"place2"] + assert r.geosearch("barcelona", member="place3", radius=100, unit="km", count=1, any=1)[0] in [ + b"place1", b"place3", b"place2"] + diff -Nru fakeredis-2.4.0/test/test_mixins/test_hash_commands.py fakeredis-2.10.3/test/test_mixins/test_hash_commands.py --- fakeredis-2.4.0/test/test_mixins/test_hash_commands.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_mixins/test_hash_commands.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,291 @@ +import pytest +import redis +import redis.client + + +# Tests for the hash type. + +def test_hstrlen_missing(r): + assert r.hstrlen('foo', 'doesnotexist') == 0 + + r.hset('foo', 'key', 'value') + assert r.hstrlen('foo', 'doesnotexist') == 0 + + +def test_hstrlen(r): + r.hset('foo', 'key', 'value') + assert r.hstrlen('foo', 'key') == 5 + + +def test_hset_then_hget(r): + assert r.hset('foo', 'key', 'value') == 1 + assert r.hget('foo', 'key') == b'value' + + +def test_hset_update(r): + assert r.hset('foo', 'key', 'value') == 1 + assert r.hset('foo', 'key', 'value') == 0 + + +def test_hset_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hset('foo', 'key', 'value') + + +def test_hgetall(r): + assert r.hset('foo', 'k1', 'v1') == 1 + assert r.hset('foo', 'k2', 'v2') == 1 + assert r.hset('foo', 'k3', 'v3') == 1 + assert r.hgetall('foo') == { + b'k1': b'v1', + b'k2': b'v2', + b'k3': b'v3' + } + + +def test_hgetall_empty_key(r): + assert r.hgetall('foo') == {} + + +def test_hgetall_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hgetall('foo') + + +def test_hexists(r): + r.hset('foo', 'bar', 'v1') + assert r.hexists('foo', 'bar') == 1 + assert r.hexists('foo', 'baz') == 0 + assert r.hexists('bar', 'bar') == 0 + + +def test_hexists_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hexists('foo', 'key') + + +def test_hkeys(r): + r.hset('foo', 'k1', 'v1') + r.hset('foo', 'k2', 'v2') + assert set(r.hkeys('foo')) == {b'k1', b'k2'} + assert set(r.hkeys('bar')) == set() + + +def test_hkeys_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hkeys('foo') + + +def test_hlen(r): + r.hset('foo', 'k1', 'v1') + r.hset('foo', 'k2', 'v2') + assert r.hlen('foo') == 2 + + +def test_hlen_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hlen('foo') + + +def test_hvals(r): + r.hset('foo', 'k1', 'v1') + r.hset('foo', 'k2', 'v2') + assert set(r.hvals('foo')) == {b'v1', b'v2'} + assert set(r.hvals('bar')) == set() + + +def test_hvals_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hvals('foo') + + +def test_hmget(r): + r.hset('foo', 'k1', 'v1') + r.hset('foo', 'k2', 'v2') + r.hset('foo', 'k3', 'v3') + # Normal case. + assert r.hmget('foo', ['k1', 'k3']) == [b'v1', b'v3'] + assert r.hmget('foo', 'k1', 'k3') == [b'v1', b'v3'] + # Key does not exist. + assert r.hmget('bar', ['k1', 'k3']) == [None, None] + assert r.hmget('bar', 'k1', 'k3') == [None, None] + # Some keys in the hash do not exist. + assert r.hmget('foo', ['k1', 'k500']) == [b'v1', None] + assert r.hmget('foo', 'k1', 'k500') == [b'v1', None] + + +def test_hmget_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hmget('foo', 'key1', 'key2') + + +def test_hdel(r): + r.hset('foo', 'k1', 'v1') + r.hset('foo', 'k2', 'v2') + r.hset('foo', 'k3', 'v3') + assert r.hget('foo', 'k1') == b'v1' + assert r.hdel('foo', 'k1') == 1 + assert r.hget('foo', 'k1') is None + assert r.hdel('foo', 'k1') == 0 + # Since redis>=2.7.6 returns number of deleted items. + assert r.hdel('foo', 'k2', 'k3') == 2 + assert r.hget('foo', 'k2') is None + assert r.hget('foo', 'k3') is None + assert r.hdel('foo', 'k2', 'k3') == 0 + + +def test_hdel_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hdel('foo', 'key') + + +def test_hincrby(r): + r.hset('foo', 'counter', 0) + assert r.hincrby('foo', 'counter') == 1 + assert r.hincrby('foo', 'counter') == 2 + assert r.hincrby('foo', 'counter') == 3 + + +def test_hincrby_with_no_starting_value(r): + assert r.hincrby('foo', 'counter') == 1 + assert r.hincrby('foo', 'counter') == 2 + assert r.hincrby('foo', 'counter') == 3 + + +def test_hincrby_with_range_param(r): + assert r.hincrby('foo', 'counter', 2) == 2 + assert r.hincrby('foo', 'counter', 2) == 4 + assert r.hincrby('foo', 'counter', 2) == 6 + + +def test_hincrby_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hincrby('foo', 'key', 2) + + +def test_hincrbyfloat(r): + r.hset('foo', 'counter', 0.0) + assert r.hincrbyfloat('foo', 'counter') == 1.0 + assert r.hincrbyfloat('foo', 'counter') == 2.0 + assert r.hincrbyfloat('foo', 'counter') == 3.0 + + +def test_hincrbyfloat_with_no_starting_value(r): + assert r.hincrbyfloat('foo', 'counter') == 1.0 + assert r.hincrbyfloat('foo', 'counter') == 2.0 + assert r.hincrbyfloat('foo', 'counter') == 3.0 + + +def test_hincrbyfloat_with_range_param(r): + assert r.hincrbyfloat('foo', 'counter', 0.1) == pytest.approx(0.1) + assert r.hincrbyfloat('foo', 'counter', 0.1) == pytest.approx(0.2) + assert r.hincrbyfloat('foo', 'counter', 0.1) == pytest.approx(0.3) + + +def test_hincrbyfloat_on_non_float_value_raises_error(r): + r.hset('foo', 'counter', 'cat') + with pytest.raises(redis.ResponseError): + r.hincrbyfloat('foo', 'counter') + + +def test_hincrbyfloat_with_non_float_amount_raises_error(r): + with pytest.raises(redis.ResponseError): + r.hincrbyfloat('foo', 'counter', 'cat') + + +def test_hincrbyfloat_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hincrbyfloat('foo', 'key', 0.1) + + +def test_hincrbyfloat_precision(r): + x = 1.23456789123456789 + assert r.hincrbyfloat('foo', 'bar', x) == x + assert float(r.hget('foo', 'bar')) == x + + +def test_hsetnx(r): + assert r.hsetnx('foo', 'newkey', 'v1') == 1 + assert r.hsetnx('foo', 'newkey', 'v1') == 0 + assert r.hget('foo', 'newkey') == b'v1' + + +def test_hmset_empty_raises_error(r): + with pytest.raises(redis.DataError): + r.hmset('foo', {}) + + +def test_hmset(r): + r.hset('foo', 'k1', 'v1') + assert r.hmset('foo', {'k2': 'v2', 'k3': 'v3'}) is True + + +def test_hmset_wrong_type(r): + r.zadd('foo', {'bar': 1}) + with pytest.raises(redis.ResponseError): + r.hmset('foo', {'key': 'value'}) + + +def test_empty_hash(r): + r.hset('foo', 'bar', 'baz') + r.hdel('foo', 'bar') + assert not r.exists('foo') + + +def test_hset_removing_last_field_delete_key(r): + r.hset(b'3L', b'f1', b'v1') + r.hdel(b'3L', b'f1') + assert r.keys('*') == [] + + +def test_hscan(r): + # Set up the data + name = 'hscan-test' + for ix in range(20): + k = 'key:%s' % ix + v = 'result:%s' % ix + r.hset(name, k, v) + expected = r.hgetall(name) + assert len(expected) == 20 # Ensure we know what we're testing + + # Test that we page through the results and get everything out + results = {} + cursor = '0' + while cursor != 0: + cursor, data = r.hscan(name, cursor, count=6) + results.update(data) + assert expected == results + + # Test the iterator version + results = {} + for key, val in r.hscan_iter(name, count=6): + results[key] = val + assert expected == results + + # Now test that the MATCH functionality works + results = {} + cursor = '0' + while cursor != 0: + cursor, data = r.hscan(name, cursor, match='*7', count=100) + results.update(data) + assert b'key:7' in results + assert b'key:17' in results + assert len(results) == 2 + + # Test the match on iterator + results = {} + for key, val in r.hscan_iter(name, match='*7'): + results[key] = val + assert b'key:7' in results + assert b'key:17' in results + assert len(results) == 2 diff -Nru fakeredis-2.4.0/test/test_mixins/test_list_commands.py fakeredis-2.10.3/test/test_mixins/test_list_commands.py --- fakeredis-2.4.0/test/test_mixins/test_list_commands.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_mixins/test_list_commands.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,599 @@ +import pytest +import redis +import redis.client +import redis.client +import threading +from time import sleep + +from .. import testtools + + +def test_lpush_then_lrange_all(r): + assert r.lpush('foo', 'bar') == 1 + assert r.lpush('foo', 'baz') == 2 + assert r.lpush('foo', 'bam', 'buzz') == 4 + assert r.lrange('foo', 0, -1) == [b'buzz', b'bam', b'baz', b'bar'] + + +def test_lpush_then_lrange_portion(r): + r.lpush('foo', 'one') + r.lpush('foo', 'two') + r.lpush('foo', 'three') + r.lpush('foo', 'four') + assert r.lrange('foo', 0, 2) == [b'four', b'three', b'two'] + assert r.lrange('foo', 0, 3) == [b'four', b'three', b'two', b'one'] + + +def test_lrange_negative_indices(r): + r.rpush('foo', 'a', 'b', 'c') + assert r.lrange('foo', -1, -2) == [] + assert r.lrange('foo', -2, -1) == [b'b', b'c'] + + +def test_lpush_key_does_not_exist(r): + assert r.lrange('foo', 0, -1) == [] + + +def test_lpush_with_nonstr_key(r): + r.lpush(1, 'one') + r.lpush(1, 'two') + r.lpush(1, 'three') + assert r.lrange(1, 0, 2) == [b'three', b'two', b'one'] + assert r.lrange('1', 0, 2) == [b'three', b'two', b'one'] + + +def test_lpush_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.lpush('foo', 'element') + + +def test_llen(r): + r.lpush('foo', 'one') + r.lpush('foo', 'two') + r.lpush('foo', 'three') + assert r.llen('foo') == 3 + + +def test_llen_no_exist(r): + assert r.llen('foo') == 0 + + +def test_llen_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.llen('foo') + + +def test_lrem_positive_count(r): + r.lpush('foo', 'same') + r.lpush('foo', 'same') + r.lpush('foo', 'different') + r.lrem('foo', 2, 'same') + assert r.lrange('foo', 0, -1) == [b'different'] + + +def test_lrem_negative_count(r): + r.lpush('foo', 'removeme') + r.lpush('foo', 'three') + r.lpush('foo', 'two') + r.lpush('foo', 'one') + r.lpush('foo', 'removeme') + r.lrem('foo', -1, 'removeme') + # Should remove it from the end of the list, + # leaving the 'removeme' from the front of the list alone. + assert r.lrange('foo', 0, -1) == [b'removeme', b'one', b'two', b'three'] + + +def test_lrem_zero_count(r): + r.lpush('foo', 'one') + r.lpush('foo', 'one') + r.lpush('foo', 'one') + r.lrem('foo', 0, 'one') + assert r.lrange('foo', 0, -1) == [] + + +def test_lrem_default_value(r): + r.lpush('foo', 'one') + r.lpush('foo', 'one') + r.lpush('foo', 'one') + r.lrem('foo', 0, 'one') + assert r.lrange('foo', 0, -1) == [] + + +def test_lrem_does_not_exist(r): + r.lpush('foo', 'one') + r.lrem('foo', 0, 'one') + # These should be noops. + r.lrem('foo', -2, 'one') + r.lrem('foo', 2, 'one') + + +def test_lrem_return_value(r): + r.lpush('foo', 'one') + count = r.lrem('foo', 0, 'one') + assert count == 1 + assert r.lrem('foo', 0, 'one') == 0 + + +def test_lrem_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.lrem('foo', 0, 'element') + + +def test_rpush(r): + r.rpush('foo', 'one') + r.rpush('foo', 'two') + r.rpush('foo', 'three') + r.rpush('foo', 'four', 'five') + assert r.lrange('foo', 0, -1) == [b'one', b'two', b'three', b'four', b'five'] + + +def test_rpush_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.rpush('foo', 'element') + + +def test_lpop(r): + assert r.rpush('foo', 'one') == 1 + assert r.rpush('foo', 'two') == 2 + assert r.rpush('foo', 'three') == 3 + assert r.lpop('foo') == b'one' + assert r.lpop('foo') == b'two' + assert r.lpop('foo') == b'three' + + +def test_lpop_empty_list(r): + r.rpush('foo', 'one') + r.lpop('foo') + assert r.lpop('foo') is None + # Verify what happens if we try to pop from a key + # we've never seen before. + assert r.lpop('noexists') is None + + +def test_lpop_zero_elem(r): + r.rpush(b'\x00', b'') + assert r.lpop(b'\x00', 0) == [] + + +def test_lpop_zero_non_existing_list(r): + assert r.lpop(b'', 0) is None + + +def test_lpop_zero_wrong_type(r): + r.set(b'', b'') + with pytest.raises(redis.ResponseError): + r.lpop(b'', 0) + + +def test_lpop_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.lpop('foo') + + +@pytest.mark.min_server('6.2') +def test_lpop_count(r): + assert r.rpush('foo', 'one') == 1 + assert r.rpush('foo', 'two') == 2 + assert r.rpush('foo', 'three') == 3 + assert testtools.raw_command(r, 'lpop', 'foo', 2) == [b'one', b'two'] + # See https://github.com/redis/redis/issues/9680 + raw = testtools.raw_command(r, 'rpop', 'foo', 0) + assert raw is None or raw == [] # https://github.com/redis/redis/pull/10095 + + +@pytest.mark.min_server('6.2') +def test_lpop_count_negative(r): + with pytest.raises(redis.ResponseError): + testtools.raw_command(r, 'lpop', 'foo', -1) + + +def test_lset(r): + r.rpush('foo', 'one') + r.rpush('foo', 'two') + r.rpush('foo', 'three') + r.lset('foo', 0, 'four') + r.lset('foo', -2, 'five') + assert r.lrange('foo', 0, -1) == [b'four', b'five', b'three'] + + +def test_lset_index_out_of_range(r): + r.rpush('foo', 'one') + with pytest.raises(redis.ResponseError): + r.lset('foo', 3, 'three') + + +def test_lset_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.lset('foo', 0, 'element') + + +def test_rpushx(r): + r.rpush('foo', 'one') + r.rpushx('foo', 'two') + r.rpushx('bar', 'three') + assert r.lrange('foo', 0, -1) == [b'one', b'two'] + assert r.lrange('bar', 0, -1) == [] + + +def test_rpushx_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.rpushx('foo', 'element') + + +def test_ltrim(r): + r.rpush('foo', 'one') + r.rpush('foo', 'two') + r.rpush('foo', 'three') + r.rpush('foo', 'four') + + assert r.ltrim('foo', 1, 3) + assert r.lrange('foo', 0, -1) == [b'two', b'three', b'four'] + assert r.ltrim('foo', 1, -1) + assert r.lrange('foo', 0, -1) == [b'three', b'four'] + + +def test_ltrim_with_non_existent_key(r): + assert r.ltrim('foo', 0, -1) + + +def test_ltrim_expiry(r): + r.rpush('foo', 'one', 'two', 'three') + r.expire('foo', 10) + r.ltrim('foo', 1, 2) + assert r.ttl('foo') > 0 + + +def test_ltrim_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.ltrim('foo', 1, -1) + + +def test_lindex(r): + r.rpush('foo', 'one') + r.rpush('foo', 'two') + assert r.lindex('foo', 0) == b'one' + assert r.lindex('foo', 4) is None + assert r.lindex('bar', 4) is None + + +def test_lindex_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.lindex('foo', 0) + + +def test_lpushx(r): + r.lpush('foo', 'two') + r.lpushx('foo', 'one') + r.lpushx('bar', 'one') + assert r.lrange('foo', 0, -1) == [b'one', b'two'] + assert r.lrange('bar', 0, -1) == [] + + +def test_lpushx_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.lpushx('foo', 'element') + + +def test_rpop(r): + assert r.rpop('foo') is None + r.rpush('foo', 'one') + r.rpush('foo', 'two') + assert r.rpop('foo') == b'two' + assert r.rpop('foo') == b'one' + assert r.rpop('foo') is None + + +def test_rpop_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.rpop('foo') + + +@pytest.mark.min_server('6.2') +def test_rpop_count(r): + assert r.rpush('foo', 'one') == 1 + assert r.rpush('foo', 'two') == 2 + assert r.rpush('foo', 'three') == 3 + assert testtools.raw_command(r, 'rpop', 'foo', 2) == [b'three', b'two'] + # See https://github.com/redis/redis/issues/9680 + raw = testtools.raw_command(r, 'rpop', 'foo', 0) + assert raw is None or raw == [] # https://github.com/redis/redis/pull/10095 + + +@pytest.mark.min_server('6.2') +def test_rpop_count_negative(r): + with pytest.raises(redis.ResponseError): + testtools.raw_command(r, 'rpop', 'foo', -1) + + +def test_linsert_before(r): + r.rpush('foo', 'hello') + r.rpush('foo', 'world') + assert r.linsert('foo', 'before', 'world', 'there') == 3 + assert r.lrange('foo', 0, -1) == [b'hello', b'there', b'world'] + + +def test_linsert_after(r): + r.rpush('foo', 'hello') + r.rpush('foo', 'world') + assert r.linsert('foo', 'after', 'hello', 'there') == 3 + assert r.lrange('foo', 0, -1) == [b'hello', b'there', b'world'] + + +def test_linsert_no_pivot(r): + r.rpush('foo', 'hello') + r.rpush('foo', 'world') + assert r.linsert('foo', 'after', 'goodbye', 'bar') == -1 + assert r.lrange('foo', 0, -1) == [b'hello', b'world'] + + +def test_linsert_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.linsert('foo', 'after', 'bar', 'element') + + +def test_rpoplpush(r): + assert r.rpoplpush('foo', 'bar') is None + assert r.lpop('bar') is None + r.rpush('foo', 'one') + r.rpush('foo', 'two') + r.rpush('bar', 'one') + + assert r.rpoplpush('foo', 'bar') == b'two' + assert r.lrange('foo', 0, -1) == [b'one'] + assert r.lrange('bar', 0, -1) == [b'two', b'one'] + + # Catch instances where we store bytes and strings inconsistently + # and thus bar = ['two', b'one'] + assert r.lrem('bar', -1, 'two') == 1 + + +def test_rpoplpush_to_nonexistent_destination(r): + r.rpush('foo', 'one') + assert r.rpoplpush('foo', 'bar') == b'one' + assert r.rpop('bar') == b'one' + + +def test_rpoplpush_expiry(r): + r.rpush('foo', 'one') + r.rpush('bar', 'two') + r.expire('bar', 10) + r.rpoplpush('foo', 'bar') + assert r.ttl('bar') > 0 + + +def test_rpoplpush_one_to_self(r): + r.rpush('list', 'element') + assert r.brpoplpush('list', 'list') == b'element' + assert r.lrange('list', 0, -1) == [b'element'] + + +def test_rpoplpush_wrong_type(r): + r.set('foo', 'bar') + r.rpush('list', 'element') + with pytest.raises(redis.ResponseError): + r.rpoplpush('foo', 'list') + assert r.get('foo') == b'bar' + assert r.lrange('list', 0, -1) == [b'element'] + with pytest.raises(redis.ResponseError): + r.rpoplpush('list', 'foo') + assert r.get('foo') == b'bar' + assert r.lrange('list', 0, -1) == [b'element'] + + +def test_blpop_single_list(r): + r.rpush('foo', 'one') + r.rpush('foo', 'two') + r.rpush('foo', 'three') + assert r.blpop(['foo'], timeout=1) == (b'foo', b'one') + + +def test_blpop_test_multiple_lists(r): + r.rpush('baz', 'zero') + assert r.blpop(['foo', 'baz'], timeout=1) == (b'baz', b'zero') + assert not r.exists('baz') + + r.rpush('foo', 'one') + r.rpush('foo', 'two') + # bar has nothing, so the returned value should come + # from foo. + assert r.blpop(['bar', 'foo'], timeout=1) == (b'foo', b'one') + r.rpush('bar', 'three') + # bar now has something, so the returned value should come + # from bar. + assert r.blpop(['bar', 'foo'], timeout=1) == (b'bar', b'three') + assert r.blpop(['bar', 'foo'], timeout=1) == (b'foo', b'two') + + +def test_blpop_allow_single_key(r): + # blpop converts single key arguments to a one element list. + r.rpush('foo', 'one') + assert r.blpop('foo', timeout=1) == (b'foo', b'one') + + +@pytest.mark.slow +def test_blpop_block(r): + def push_thread(): + sleep(0.5) + r.rpush('foo', 'value1') + sleep(0.5) + # Will wake the condition variable + r.set('bar', 'go back to sleep some more') + r.rpush('foo', 'value2') + + thread = threading.Thread(target=push_thread) + thread.start() + try: + assert r.blpop('foo') == (b'foo', b'value1') + assert r.blpop('foo', timeout=5) == (b'foo', b'value2') + finally: + thread.join() + + +def test_blpop_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.blpop('foo', timeout=1) + + +def test_blpop_transaction(r): + p = r.pipeline() + p.multi() + p.blpop('missing', timeout=1000) + result = p.execute() + # Blocking commands behave like non-blocking versions in transactions + assert result == [None] + + +def test_brpop_test_multiple_lists(r): + r.rpush('baz', 'zero') + assert r.brpop(['foo', 'baz'], timeout=1) == (b'baz', b'zero') + assert not r.exists('baz') + + r.rpush('foo', 'one') + r.rpush('foo', 'two') + assert r.brpop(['bar', 'foo'], timeout=1) == (b'foo', b'two') + + +def test_brpop_single_key(r): + r.rpush('foo', 'one') + r.rpush('foo', 'two') + assert r.brpop('foo', timeout=1) == (b'foo', b'two') + + +@pytest.mark.slow +def test_brpop_block(r): + def push_thread(): + sleep(0.5) + r.rpush('foo', 'value1') + sleep(0.5) + # Will wake the condition variable + r.set('bar', 'go back to sleep some more') + r.rpush('foo', 'value2') + + thread = threading.Thread(target=push_thread) + thread.start() + try: + assert r.brpop('foo') == (b'foo', b'value1') + assert r.brpop('foo', timeout=5) == (b'foo', b'value2') + finally: + thread.join() + + +def test_brpop_wrong_type(r): + r.set('foo', 'bar') + with pytest.raises(redis.ResponseError): + r.brpop('foo', timeout=1) + + +def test_brpoplpush_multi_keys(r): + assert r.lpop('bar') is None + r.rpush('foo', 'one') + r.rpush('foo', 'two') + assert r.brpoplpush('foo', 'bar', timeout=1) == b'two' + assert r.lrange('bar', 0, -1) == [b'two'] + + # Catch instances where we store bytes and strings inconsistently + # and thus bar = ['two'] + assert r.lrem('bar', -1, 'two') == 1 + + +def test_brpoplpush_wrong_type(r): + r.set('foo', 'bar') + r.rpush('list', 'element') + with pytest.raises(redis.ResponseError): + r.brpoplpush('foo', 'list') + assert r.get('foo') == b'bar' + assert r.lrange('list', 0, -1) == [b'element'] + with pytest.raises(redis.ResponseError): + r.brpoplpush('list', 'foo') + assert r.get('foo') == b'bar' + assert r.lrange('list', 0, -1) == [b'element'] + + +@pytest.mark.slow +def test_blocking_operations_when_empty(r): + assert r.blpop(['foo'], timeout=1) is None + assert r.blpop(['bar', 'foo'], timeout=1) is None + assert r.brpop('foo', timeout=1) is None + assert r.brpoplpush('foo', 'bar', timeout=1) is None + + +def test_empty_list(r): + r.rpush('foo', 'bar') + r.rpop('foo') + assert not r.exists('foo') + + +def test_lmove_to_nonexistent_destination(r): + r.rpush('foo', 'one') + assert r.lmove('foo', 'bar', 'RIGHT', 'LEFT') == b'one' + assert r.rpop('bar') == b'one' + + +def test_lmove_expiry(r): + r.rpush('foo', 'one') + r.rpush('bar', 'two') + r.expire('bar', 10) + r.lmove('foo', 'bar', 'RIGHT', 'LEFT') + assert r.ttl('bar') > 0 + + +def test_lmove_wrong_type(r): + r.set('foo', 'bar') + r.rpush('list', 'element') + with pytest.raises(redis.ResponseError): + r.lmove('foo', 'list', 'RIGHT', 'LEFT') + assert r.get('foo') == b'bar' + assert r.lrange('list', 0, -1) == [b'element'] + with pytest.raises(redis.ResponseError): + r.lmove('list', 'foo', 'RIGHT', 'LEFT') + assert r.get('foo') == b'bar' + assert r.lrange('list', 0, -1) == [b'element'] + + +def test_lmove(r): + assert r.lmove('foo', 'bar', 'RIGHT', 'LEFT') is None + assert r.lpop('bar') is None + r.rpush('foo', 'one') + r.rpush('foo', 'two') + r.rpush('bar', 'one') + + # RPOPLPUSH + assert r.lmove('foo', 'bar', 'RIGHT', 'LEFT') == b'two' + assert r.lrange('foo', 0, -1) == [b'one'] + assert r.lrange('bar', 0, -1) == [b'two', b'one'] + # LPOPRPUSH + assert r.lmove('bar', 'bar', 'LEFT', 'RIGHT') == b'two' + assert r.lrange('bar', 0, -1) == [b'one', b'two'] + # RPOPRPUSH + r.rpush('foo', 'three') + assert r.lmove('foo', 'bar', 'RIGHT', 'RIGHT') == b'three' + assert r.lrange('foo', 0, -1) == [b'one'] + assert r.lrange('bar', 0, -1) == [b'one', b'two', b'three'] + # LPOPLPUSH + assert r.lmove('bar', 'foo', 'LEFT', 'LEFT') == b'one' + assert r.lrange('foo', 0, -1) == [b'one', b'one'] + assert r.lrange('bar', 0, -1) == [b'two', b'three'] + + # Catch instances where we store bytes and strings inconsistently + # and thus bar = ['two', b'one'] + assert r.lrem('bar', -1, 'two') == 1 + + +@pytest.mark.disconnected +@testtools.fake_only +def test_lmove_disconnected_raises_connection_error(r): + with pytest.raises(redis.ConnectionError): + r.lmove(1, 2, 'LEFT', 'RIGHT') diff -Nru fakeredis-2.4.0/test/test_mixins/test_pubsub_commands.py fakeredis-2.10.3/test/test_mixins/test_pubsub_commands.py --- fakeredis-2.4.0/test/test_mixins/test_pubsub_commands.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_mixins/test_pubsub_commands.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,407 @@ +import uuid + +import pytest +import redis +import threading +from queue import Queue +from time import sleep + +import fakeredis +from .. import testtools +from ..testtools import raw_command + + +def test_ping_pubsub(r): + p = r.pubsub() + p.subscribe('channel') + p.parse_response() # Consume the subscribe command reply + p.ping() + assert p.parse_response() == [b'pong', b''] + p.ping('test') + assert p.parse_response() == [b'pong', b'test'] + + +@pytest.mark.slow +def test_pubsub_subscribe(r): + pubsub = r.pubsub() + pubsub.subscribe("channel") + sleep(1) + expected_message = {'type': 'subscribe', 'pattern': None, + 'channel': b'channel', 'data': 1} + message = pubsub.get_message() + keys = list(pubsub.channels.keys()) + + key = keys[0] + key = (key if type(key) == bytes + else bytes(key, encoding='utf-8')) + + assert len(keys) == 1 + assert key == b'channel' + assert message == expected_message + + +@pytest.mark.slow +def test_pubsub_psubscribe(r): + pubsub = r.pubsub() + pubsub.psubscribe("channel.*") + sleep(1) + expected_message = {'type': 'psubscribe', 'pattern': None, + 'channel': b'channel.*', 'data': 1} + + message = pubsub.get_message() + keys = list(pubsub.patterns.keys()) + assert len(keys) == 1 + assert message == expected_message + + +@pytest.mark.slow +def test_pubsub_unsubscribe(r): + pubsub = r.pubsub() + pubsub.subscribe('channel-1', 'channel-2', 'channel-3') + sleep(1) + expected_message = {'type': 'unsubscribe', 'pattern': None, + 'channel': b'channel-1', 'data': 2} + pubsub.get_message() + pubsub.get_message() + pubsub.get_message() + + # unsubscribe from one + pubsub.unsubscribe('channel-1') + sleep(1) + message = pubsub.get_message() + keys = list(pubsub.channels.keys()) + assert message == expected_message + assert len(keys) == 2 + + # unsubscribe from multiple + pubsub.unsubscribe() + sleep(1) + pubsub.get_message() + pubsub.get_message() + keys = list(pubsub.channels.keys()) + assert message == expected_message + assert len(keys) == 0 + + +@pytest.mark.slow +def test_pubsub_punsubscribe(r): + pubsub = r.pubsub() + pubsub.psubscribe('channel-1.*', 'channel-2.*', 'channel-3.*') + sleep(1) + expected_message = {'type': 'punsubscribe', 'pattern': None, + 'channel': b'channel-1.*', 'data': 2} + pubsub.get_message() + pubsub.get_message() + pubsub.get_message() + + # unsubscribe from one + pubsub.punsubscribe('channel-1.*') + sleep(1) + message = pubsub.get_message() + keys = list(pubsub.patterns.keys()) + assert message == expected_message + assert len(keys) == 2 + + # unsubscribe from multiple + pubsub.punsubscribe() + sleep(1) + pubsub.get_message() + pubsub.get_message() + keys = list(pubsub.patterns.keys()) + assert len(keys) == 0 + + +@pytest.mark.slow +def test_pubsub_listen(r): + def _listen(pubsub, q): + count = 0 + for message in pubsub.listen(): + q.put(message) + count += 1 + if count == 4: + pubsub.close() + + channel = 'ch1' + patterns = ['ch1*', 'ch[1]', 'ch?'] + pubsub = r.pubsub() + pubsub.subscribe(channel) + pubsub.psubscribe(*patterns) + sleep(1) + msg1 = pubsub.get_message() + msg2 = pubsub.get_message() + msg3 = pubsub.get_message() + msg4 = pubsub.get_message() + assert msg1['type'] == 'subscribe' + assert msg2['type'] == 'psubscribe' + assert msg3['type'] == 'psubscribe' + assert msg4['type'] == 'psubscribe' + + q = Queue() + t = threading.Thread(target=_listen, args=(pubsub, q)) + t.start() + msg = 'hello world' + r.publish(channel, msg) + t.join() + + msg1 = q.get() + msg2 = q.get() + msg3 = q.get() + msg4 = q.get() + + bpatterns = [pattern.encode() for pattern in patterns] + bpatterns.append(channel.encode()) + msg = msg.encode() + assert msg1['data'] == msg + assert msg1['channel'] in bpatterns + assert msg2['data'] == msg + assert msg2['channel'] in bpatterns + assert msg3['data'] == msg + assert msg3['channel'] in bpatterns + assert msg4['data'] == msg + assert msg4['channel'] in bpatterns + + +@pytest.mark.slow +def test_pubsub_listen_handler(r): + def _handler(message): + calls.append(message) + + channel = 'ch1' + patterns = {'ch?': _handler} + calls = [] + + pubsub = r.pubsub() + pubsub.subscribe(ch1=_handler) + pubsub.psubscribe(**patterns) + sleep(1) + msg1 = pubsub.get_message() + msg2 = pubsub.get_message() + assert msg1['type'] == 'subscribe' + assert msg2['type'] == 'psubscribe' + msg = 'hello world' + r.publish(channel, msg) + sleep(1) + for i in range(2): + msg = pubsub.get_message() + assert msg is None # get_message returns None when handler is used + pubsub.close() + calls.sort(key=lambda call: call['type']) + assert calls == [ + {'pattern': None, 'channel': b'ch1', 'data': b'hello world', 'type': 'message'}, + {'pattern': b'ch?', 'channel': b'ch1', 'data': b'hello world', 'type': 'pmessage'} + ] + + +@pytest.mark.slow +def test_pubsub_ignore_sub_messages_listen(r): + def _listen(pubsub, q): + count = 0 + for message in pubsub.listen(): + q.put(message) + count += 1 + if count == 4: + pubsub.close() + + channel = 'ch1' + patterns = ['ch1*', 'ch[1]', 'ch?'] + pubsub = r.pubsub(ignore_subscribe_messages=True) + pubsub.subscribe(channel) + pubsub.psubscribe(*patterns) + sleep(1) + + q = Queue() + t = threading.Thread(target=_listen, args=(pubsub, q)) + t.start() + msg = 'hello world' + r.publish(channel, msg) + t.join() + + msg1 = q.get() + msg2 = q.get() + msg3 = q.get() + msg4 = q.get() + + bpatterns = [pattern.encode() for pattern in patterns] + bpatterns.append(channel.encode()) + msg = msg.encode() + assert msg1['data'] == msg + assert msg1['channel'] in bpatterns + assert msg2['data'] == msg + assert msg2['channel'] in bpatterns + assert msg3['data'] == msg + assert msg3['channel'] in bpatterns + assert msg4['data'] == msg + assert msg4['channel'] in bpatterns + + +@pytest.mark.slow +def test_pubsub_binary(r): + def _listen(pubsub, q): + for message in pubsub.listen(): + q.put(message) + pubsub.close() + + pubsub = r.pubsub(ignore_subscribe_messages=True) + pubsub.subscribe('channel\r\n\xff') + sleep(1) + + q = Queue() + t = threading.Thread(target=_listen, args=(pubsub, q)) + t.start() + msg = b'\x00hello world\r\n\xff' + r.publish('channel\r\n\xff', msg) + t.join() + + received = q.get() + assert received['data'] == msg + + +@pytest.mark.slow +def test_pubsub_run_in_thread(r): + q = Queue() + + pubsub = r.pubsub() + pubsub.subscribe(channel=q.put) + pubsub_thread = pubsub.run_in_thread() + + msg = b"Hello World" + r.publish("channel", msg) + + retrieved = q.get() + assert retrieved["data"] == msg + + pubsub_thread.stop() + # Newer versions of redis wait for an unsubscribe message, which sometimes comes early + # https://github.com/andymccurdy/redis-py/issues/1150 + if pubsub.channels: + pubsub.channels = {} + pubsub_thread.join() + assert not pubsub_thread.is_alive() + + pubsub.subscribe(channel=None) + with pytest.raises(redis.exceptions.PubSubError): + pubsub_thread = pubsub.run_in_thread() + + pubsub.unsubscribe("channel") + + pubsub.psubscribe(channel=None) + with pytest.raises(redis.exceptions.PubSubError): + pubsub_thread = pubsub.run_in_thread() + + +@pytest.mark.slow +@pytest.mark.parametrize( + "timeout_value", + [ + 1, + pytest.param( + None, + marks=testtools.run_test_if_redispy_ver('above', '3.2') + ) + ] +) +def test_pubsub_timeout(r, timeout_value): + def publish(): + sleep(0.1) + r.publish('channel', 'hello') + + p = r.pubsub() + p.subscribe('channel') + p.parse_response() # Drains the subscribe command message + publish_thread = threading.Thread(target=publish) + publish_thread.start() + message = p.get_message(timeout=timeout_value) + assert message == { + 'type': 'message', 'pattern': None, + 'channel': b'channel', 'data': b'hello' + } + publish_thread.join() + + if timeout_value is not None: + # For infinite timeout case don't wait for the message that will never appear. + message = p.get_message(timeout=timeout_value) + assert message is None + + +@pytest.mark.fake +def test_socket_cleanup_pubsub(fake_server): + r1 = fakeredis.FakeStrictRedis(server=fake_server) + r2 = fakeredis.FakeStrictRedis(server=fake_server) + ps = r1.pubsub() + with ps: + ps.subscribe('test') + ps.psubscribe('test*') + r2.publish('test', 'foo') + + +def test_pubsub_channels(r): + p = r.pubsub() + p.subscribe("foo", "bar", "baz", "test") + expected = {b"foo", b"bar", b"baz", b"test"} + assert set(r.pubsub_channels()) == expected + + +def test_pubsub_channels_pattern(r): + p = r.pubsub() + p.subscribe("foo", "bar", "baz", "test") + assert set(r.pubsub_channels("b*")) == {b"bar", b"baz", } + + +def test_pubsub_no_subcommands(r): + with pytest.raises(redis.ResponseError): + raw_command(r, "PUBSUB") + + +@pytest.mark.min_server('7') +def test_pubsub_help_redis7(r): + assert raw_command(r, "PUBSUB HELP") == [ + b'PUBSUB [ [value] [opt] ...]. Subcommands are:', + b'CHANNELS []', + b" Return the currently active channels matching a (default: '*')" + b'.', + b'NUMPAT', + b' Return number of subscriptions to patterns.', + b'NUMSUB [ ...]', + b' Return the number of subscribers for the specified channels, excluding', + b' pattern subscriptions(default: no channels).', + b'SHARDCHANNELS []', + b' Return the currently active shard level channels matching a (d' + b"efault: '*').", + b'SHARDNUMSUB [ ...]', + b' Return the number of subscribers for the specified shard level channel(s' + b')', + b'HELP', + b' Prints this help.' + ] + + +@pytest.mark.max_server('6.2.7') +def test_pubsub_help_redis6(r): + assert raw_command(r, "PUBSUB HELP") == [ + b'PUBSUB [ [value] [opt] ...]. Subcommands are:', + b'CHANNELS []', + b" Return the currently active channels matching a (default: '*')" + b'.', + b'NUMPAT', + b' Return number of subscriptions to patterns.', + b'NUMSUB [ ...]', + b' Return the number of subscribers for the specified channels, excluding', + b' pattern subscriptions(default: no channels).', + b'HELP', + b' Prints this help.' + ] + + +def test_pubsub_numsub(r): + a = uuid.uuid4().hex + b = uuid.uuid4().hex + c = uuid.uuid4().hex + p1 = r.pubsub() + p2 = r.pubsub() + + p1.subscribe(a, b, c) + p2.subscribe(a, b) + + assert r.pubsub_numsub(a, b, c) == [(a.encode(), 2), (b.encode(), 2), (c.encode(), 1), ] + assert r.pubsub_numsub() == [] + assert r.pubsub_numsub(a, "non-existing") == [(a.encode(), 2), (b"non-existing", 0)] diff -Nru fakeredis-2.4.0/test/test_mixins/test_scripting.py fakeredis-2.10.3/test/test_mixins/test_scripting.py --- fakeredis-2.4.0/test/test_mixins/test_scripting.py 1970-01-01 00:00:00.000000000 +0000 +++ fakeredis-2.10.3/test/test_mixins/test_scripting.py 2023-04-03 23:14:58.072066300 +0000 @@ -0,0 +1,99 @@ +from __future__ import annotations + +import pytest +import redis +import redis.client + +from test.testtools import raw_command + + +@pytest.mark.min_server('7') +def test_script_exists_redis7(r): + # test response for no arguments by bypassing the py-redis command + # as it requires at least one argument + with pytest.raises(redis.ResponseError): + raw_command(r, "SCRIPT EXISTS") + + # use single character characters for non-existing scripts, as those + # will never be equal to an actual sha1 hash digest + assert r.script_exists("a") == [0] + assert r.script_exists("a", "b", "c", "d", "e", "f") == [0, 0, 0, 0, 0, 0] + + sha1_one = r.script_load("return 'a'") + assert r.script_exists(sha1_one) == [1] + assert r.script_exists(sha1_one, "a") == [1, 0] + assert r.script_exists("a", "b", "c", sha1_one, "e") == [0, 0, 0, 1, 0] + + sha1_two = r.script_load("return 'b'") + assert r.script_exists(sha1_one, sha1_two) == [1, 1] + assert r.script_exists("a", sha1_one, "c", sha1_two, "e", "f") == [0, 1, 0, 1, 0, 0] + + +@pytest.mark.max_server('6.2.7') +def test_script_exists_redis6(r): + # test response for no arguments by bypassing the py-redis command + # as it requires at least one argument + assert raw_command(r, "SCRIPT EXISTS") == [] + + # use single character characters for non-existing scripts, as those + # will never be equal to an actual sha1 hash digest + assert r.script_exists("a") == [0] + assert r.script_exists("a", "b", "c", "d", "e", "f") == [0, 0, 0, 0, 0, 0] + + sha1_one = r.script_load("return 'a'") + assert r.script_exists(sha1_one) == [1] + assert r.script_exists(sha1_one, "a") == [1, 0] + assert r.script_exists("a", "b", "c", sha1_one, "e") == [0, 0, 0, 1, 0] + + sha1_two = r.script_load("return 'b'") + assert r.script_exists(sha1_one, sha1_two) == [1, 1] + assert r.script_exists("a", sha1_one, "c", sha1_two, "e", "f") == [0, 1, 0, 1, 0, 0] + + +@pytest.mark.parametrize("args", [("a",), tuple("abcdefghijklmn")]) +def test_script_flush_errors_with_args(r, args): + with pytest.raises(redis.ResponseError): + raw_command(r, "SCRIPT FLUSH %s" % " ".join(args)) + + +def test_script_flush(r): + # generate/load six unique scripts and store their sha1 hash values + sha1_values = [r.script_load("return '%s'" % char) for char in "abcdef"] + + # assert the scripts all exist prior to flushing + assert r.script_exists(*sha1_values) == [1] * len(sha1_values) + + # flush and assert OK response + assert r.script_flush() is True + + # assert none of the scripts exists after flushing + assert r.script_exists(*sha1_values) == [0] * len(sha1_values) + + +def test_script_no_subcommands(r): + with pytest.raises(redis.ResponseError): + raw_command(r, "SCRIPT") + + +def test_script_help(r): + assert raw_command(r, "SCRIPT HELP") == [ + b'SCRIPT [ [value] [opt] ...]. Subcommands are:', + b'DEBUG (YES|SYNC|NO)', + b' Set the debug mode for subsequent scripts executed.', + b'EXISTS [ ...]', + b' Return information about the existence of the scripts in the script cach' + b'e.', + b'FLUSH [ASYNC|SYNC]', + b' Flush the Lua scripts cache. Very dangerous on replicas.', + b' When called without the optional mode argument, the behavior is determin' + b'ed by the', + b' lazyfree-lazy-user-flush configuration directive. Valid modes are:', + b' * ASYNC: Asynchronously flush the scripts cache.', + b' * SYNC: Synchronously flush the scripts cache.', + b'KILL', + b' Kill the currently executing Lua script.', + b'LOAD