diff --git a/redisdol/base.py b/redisdol/base.py index ac1ac8d..9309d5a 100644 --- a/redisdol/base.py +++ b/redisdol/base.py @@ -79,7 +79,10 @@ def __iter__(self): yield from self._source.keys() def __getitem__(self, k): - return self._source.get(k) + v = self._source.get(k) + if v is None: + raise KeyError(k) + return v def __contains__(self, k): return k in self._source @@ -90,7 +93,10 @@ def __len__(self): class RedisBytesReader(RedisBytesCollection, KvReader): def __getitem__(self, k): - return self._source.get(k) + v = self._source.get(k) + if v is None: # redis GET answers nil for a missing key; Mapping says KeyError + raise KeyError(k) + return v class RedisBytesPersister(RedisBytesReader, KvPersister): @@ -298,10 +304,22 @@ def _mk_list(self, k): def __getitem__(self, k): val_type = self._source.type(k) + if isinstance(val_type, str): # connection made with decode_responses=True + val_type = val_type.encode() if val_type == b"string": - return self._source.get(k) + v = self._source.get(k) + if v is None: # expired/deleted between TYPE and GET + raise KeyError(k) + return v elif val_type == b"list": return self._mk_list(k) + elif val_type == b"none": + raise KeyError(k) + else: # hash, set, zset, stream...: present but not representable here + raise TypeError( + f"Key {k!r} holds a Redis {val_type.decode()!r}, " + "which this store can't read (only strings and lists)" + ) class RedisPersister(RedisCollection, RedisBytesPersister): diff --git a/redisdol/stores.py b/redisdol/stores.py index d7f1b5a..1058f99 100644 --- a/redisdol/stores.py +++ b/redisdol/stores.py @@ -8,6 +8,7 @@ def _convert_numeric_list(data, output_type=list): if isinstance(data, RedisList): return output_type(map(cast_to_numeric_if_possible, data)) + return data # not a list (e.g. a string value): pass through untouched @wrap_kvs(obj_of_data=_convert_numeric_list) @@ -19,5 +20,11 @@ class RedisStoreWithNumericLists(RedisPersister): >>> s['__test_numeric_list'] = [1,-2,-3.0, 4e-23, 'not_a_number'] >>> s['__test_numeric_list'] [1, -2, -3.0, 4e-23, b'not_a_number'] - >>> del s['__test_numeric_list'] + >>> s['__test_str'] = 'hello' + >>> s['__test_str'] # non-list values pass through (they used to come back as None) + b'hello' + >>> del s['__test_numeric_list'], s['__test_str'] + + Note that a list comes back as a detached python ``list`` (a snapshot): mutating + it does not write through to Redis, unlike the `RedisList` handle of `RedisPersister`. """ diff --git a/tests/test_wrapping_fakeredis.py b/tests/test_wrapping_fakeredis.py index 59c39a6..243f9be 100644 --- a/tests/test_wrapping_fakeredis.py +++ b/tests/test_wrapping_fakeredis.py @@ -108,3 +108,67 @@ def test_value_wrap_over_store_still_works_for_strings(monkeypatch): w = wrap_kvs(RedisPersister, obj_of_data=lambda d: d)() w["s"] = "v" assert w["s"] == b"v" + + +# -- Mapping contract: missing keys raise KeyError (redisdol#4) --------------------- + + +@pytest.mark.parametrize("cls", [RedisBytesPersister, RedisPersister]) +def test_missing_key_raises_keyerror(cls): + s = _store(cls) + with pytest.raises(KeyError): + s["absent"] + assert s.get("absent", "DEFAULT") == "DEFAULT" + assert "absent" not in s + s["present"] = "v" + assert s.get("present") == b"v" + + +def test_reader_missing_key_raises_keyerror(): + from redisdol.base import RedisBytesReader + + r = store_from_source(FakeRedis(), RedisBytesReader) + with pytest.raises(KeyError): + r["absent"] + assert r.get("absent", 0) == 0 + + +def test_dict_of_store_roundtrips(): + s = _store() + s["a"] = "x" + s["b"] = ["1", "2"] + d = dict(s) + assert d[b"a"] == b"x" and list(d[b"b"]) == [b"1", b"2"] + + +# -- RedisStoreWithNumericLists passes non-lists through (redisdol#5) ---------------- + + +def test_numeric_lists_store_passes_strings_through(monkeypatch): + from redisdol.stores import RedisStoreWithNumericLists, _convert_numeric_list + + monkeypatch.setattr("redisdol.base.Redis", FakeRedis) + s = RedisStoreWithNumericLists() + s["str"] = "hello" + s["nums"] = [1, -2, 3.5, "nope"] + assert s["str"] == b"hello" + assert s["nums"] == [1, -2, 3.5, b"nope"] + assert dict(s) == {b"str": b"hello", b"nums": [1, -2, 3.5, b"nope"]} + assert _convert_numeric_list("plain") == "plain" + + +def test_decode_responses_connection_missing_and_present_keys(): + s = store_from_source(FakeRedis(decode_responses=True), RedisPersister) + s["k"] = "v" + s["l"] = ["a"] + assert s["k"] == "v" and list(s["l"]) == ["a"] + with pytest.raises(KeyError): + s["absent"] + + +def test_unsupported_redis_type_raises_typeerror_not_none(): + s = _store() + s._source.hset("h", "f", "v") + assert "h" in s + with pytest.raises(TypeError, match="hash"): + s["h"]