diff --git a/aiomisc/cache/__init__.py b/aiomisc/cache/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/aiomisc/cache/__init__.py @@ -0,0 +1 @@ + diff --git a/aiomisc/cache/base.py b/aiomisc/cache/base.py new file mode 100644 index 00000000..1edb9ad4 --- /dev/null +++ b/aiomisc/cache/base.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod +from typing import Any, Hashable, Dict + + +class CachePolicy(ABC): + __slots__ = "max_size", "cache" + + def __init__(self, cache: Dict[Hashable, Any], max_size: int = 0): + self.max_size = max_size + self.cache = cache + self._on_init() + + def _on_init(self): + pass + + @abstractmethod + def get(self, key: Hashable): + raise NotImplementedError + + @abstractmethod + def remove(self, key: Hashable): + raise NotImplementedError + + @abstractmethod + def set(self, key: Hashable, value: Any): + raise NotImplementedError + + @abstractmethod + def __contains__(self, key: Hashable): + raise NotImplementedError + + @abstractmethod + def __len__(self): + raise NotImplementedError diff --git a/aiomisc/cache/dllist.py b/aiomisc/cache/dllist.py new file mode 100644 index 00000000..e86791ef --- /dev/null +++ b/aiomisc/cache/dllist.py @@ -0,0 +1,239 @@ +from multiprocessing import RLock +from typing import Optional, Any, Hashable, Type + + +class Node: + __slots__ = 'prev', 'next', 'value', 'parent' + + prev: Optional["Node"] + next: Optional["Node"] + parent: "DLList" + + def __init__(self, parent: "DLList", prev: "Node" = None, + next: "Node" = None): + self.parent = parent + self.prev = prev + self.next = next + self.value = None + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} " \ + f"{id(self)}: next={id(self.next)} " \ + f"prev={id(self.prev)}>" + + def remove(self): + if self.next is None: + return + + with self.parent.lock: + self.prev.next = self.next + self.prev = None + self.next = None + + def append_left(self, node: "Node") -> "Node": + """ + Appends node before the parent node + + before: + + ... <-> [self] <-> ... + + after: + + ... <-> [node] <-> [self] <-> ... + + """ + with self.parent.lock: + self.parent.nodes.add(node) + node.next = self + + if self.prev is not None: + node.prev = self.prev + + self.prev = node + return node + + def append_right(self, node: "Node") -> "Node": + """ + Appends node after parent node + + before: + + ... <-> [self] <-> ... + + after: + + ... <-> [self] <-> [node] <-> ... + + """ + + with self.parent.lock: + self.parent.nodes.add(node) + node.prev = self + + if self.next is not None: + node.next = self.next + + self.next = node + return node + + def swap(self, other: "Node"): + """ + Swaps two Nodes and change neighbor links + + Example: doubly linked list looks like: + + [x] <-> [self] <-> [z] <-> [other] <-> [y] + node x node self node z node other node y + p n p n p n p n p n + ------------------------------------------------------- + - self x z self other z y other - + + After swap should looks like: + + [x] <-> [other] <-> [z] <-> [self] <-> [y] + node x node other node z node self node y + p n p n p n p n p n + -------------------------------------------------------- + - other x z other b z y other - + + That's means we should make 8 changes + + # 4 for "a" and "b" + a.prev, a.next, b.prev, b.next = b.prev, b.next, a.prev, a.next + + # 4 for neighbors + x.next, z.prev, z.next, y.prev = b, b, a, a + + After general case is should be: + + a.prev.next, a.next.prev, b.prev.next, b.next.prev = b, b, a, a + a.prev, a.next, b.prev, b.next = b.prev, b.next, a.prev, a.next + + """ + with self.parent.lock: + # store original links + self_prev, self_next, other_prev, other_next = ( + self.prev, self.next, other.prev, other.next + ) + + if self_prev is not None: + self_prev.next = other + self.prev = other_prev + + if self_next is not None: + self_next.prev = other + self.next = other_next + + if other_next is not None: + other_next.prev = self + other.next = other_next + + if other_prev is not None: + other_prev.next = self + other.prev = self_prev + + first_set = False + last_set = False + + if not last_set and self is self.parent.last: + self.parent.last = other + last_set = True + + if not first_set and self is self.parent.first: + self.parent.first = other + first_set = True + + if not last_set and other is self.parent.last: + self.parent.last = self + + if not first_set and other is self.parent.first: + self.parent.first = self + + +class Item: + __slots__ = 'node', 'key', 'value' + + node: Node + key: Hashable + value: Any + + def __init__(self, node: Node, key: Hashable, value: Any): + self.node = node + self.key = key + self.value = value + + +class DLList: + __slots__ = 'first', 'last', 'lock', 'nodes' + + NODE_CLASS: Type[Node] = Node + ITEM_CLASS: Type[Item] = Item + + first: Optional[NODE_CLASS] + last: Optional[NODE_CLASS] + + def __init__(self): + self.lock = RLock() + self.first = None + self.last = None + self.nodes = set() + + def __len__(self): + return len(self.nodes) + + def __contains__(self, item: NODE_CLASS): + return item in self.nodes + + def __iter__(self): + with self.lock: + first = self.first + while first is not None: + yield first + first = first.next + + def _create_node(self, *args, **kwargs): + node = self.NODE_CLASS(self, *args, **kwargs) + self.nodes.add(node) + return node + + def remove(self, node: NODE_CLASS): + if node not in self.nodes: + raise ValueError(f"Node {node!r} is not part of {self!r}") + + with self.lock: + self.nodes.remove(node) + if node.prev is not None: + node.prev.next = node.next + if node.next is not None: + node.next.prev = node.prev + + if self.first is node: + self.first = node.next + + if self.last is node: + self.last = node.prev + + def create_left(self) -> NODE_CLASS: + with self.lock: + if self.first is None: + self.first = self._create_node() + self.last = self.first + return self.first + + node = self._create_node(next=self.first) + self.first.prev = node + self.first = node + return node + + def create_right(self) -> NODE_CLASS: + with self.lock: + if self.first is None: + self.last = self._create_node() + self.first = self.last + return self.first + + node = self._create_node(prev=self.last) + self.last.next = node + self.last = node + return node diff --git a/aiomisc/cache/lfu.py b/aiomisc/cache/lfu.py new file mode 100644 index 00000000..465aa2e9 --- /dev/null +++ b/aiomisc/cache/lfu.py @@ -0,0 +1,110 @@ +from threading import RLock +from typing import Any, Hashable, Optional, Dict, Set +from aiomisc.cache.base import CachePolicy +from llist import dllist, dllistnode + +from aiomisc.cache.dllist import Item + + +class LFUCachePolicy(CachePolicy): + """ + LFU cache implementation + + >>> cache = {} + >>> lfu = LFUCachePolicy(cache, 3) + >>> lfu.set("foo", "bar") + >>> assert "foo" in lfu + >>> lfu.get('foo') + 'bar' + + >>> lfu.remove('foo') + >>> assert "foo" not in lfu + >>> lfu.get("foo") + Traceback (most recent call last): + ... + KeyError: 'foo' + >>> lfu.remove("foo") + + >>> lfu.set("bar", "foo") + >>> lfu.set("spam", "egg") + >>> lfu.set("foo", "bar") + >>> lfu.get("foo") + 'bar' + >>> lfu.get("spam") + 'egg' + >>> assert len(lfu) == 3 + >>> lfu.set("egg", "spam") + >>> assert len(lfu) == 3 + """ + + __slots__ = "usages", "lock" + + def _on_init(self): + self.usages: dllist = dllist() + self.lock: RLock = RLock() + + def _update_usage(self, item: Item): + with self.lock: + pass + + def _item_remove(self, item: Item): + with self.lock: + if item in item.node.value: + item.node.value.remove(item) + + item.node = None + + def _on_overflow(self): + with self.lock: + while self._is_overflow(): + if not self.usages.value: + if self.usages.next is not None: + self.usages.next.prev = None + self.usages = self.usages.next + else: + self.usages = Node(prev=None, next=None) + continue + + item = self.usages.value.pop() + self.cache.pop(item.key, None) + self._item_remove(item) + + def _is_overflow(self) -> bool: + return len(self.cache) > self.max_size + + def __contains__(self, key: Hashable) -> Any: + if key in self.cache: + self._update_usage(self.cache[key]) + return True + return False + + def __len__(self): + return len(self.cache) + + def get(self, key: Hashable): + item: Item = self.cache[key] + self._update_usage(item) + return item.value + + def remove(self, key: Hashable): + with self.lock: + item: Optional[Item] = self.cache.pop(key, None) + if item is None: + return + + self._item_remove(item) + + def set(self, key: Hashable, value: Any): + with self.lock: + node: Optional[Node] = self.usages + + if node is None: + node = Node() + self.usages = node + + item = Item(node=node, key=key, value=value) + node.value.add(item) + self.cache[key] = item + + if self._is_overflow(): + self._on_overflow() diff --git a/aiomisc/cache/lru.py b/aiomisc/cache/lru.py new file mode 100644 index 00000000..6eb0c58b --- /dev/null +++ b/aiomisc/cache/lru.py @@ -0,0 +1,101 @@ +from threading import RLock +from typing import Any, Hashable, Dict, Optional + +from llist import dllist, dllistnode + +from aiomisc.cache.base import CachePolicy +from aiomisc.cache.dllist import Item + + +class LRUCachePolicy(CachePolicy): + """ + LRU cache implementation + + >>> cache = {} + >>> lfu = LRUCachePolicy(cache, 3) + >>> lfu.set("foo", "bar") + >>> assert "foo" in lfu + >>> lfu.get('foo') + 'bar' + + >>> lfu.remove('foo') + >>> assert "foo" not in lfu + >>> lfu.get("foo") + Traceback (most recent call last): + ... + KeyError: 'foo' + >>> lfu.remove("foo") + + >>> lfu.set("bar", "foo") + >>> lfu.set("spam", "egg") + >>> lfu.set("foo", "bar") + >>> lfu.get("foo") + 'bar' + >>> lfu.get("spam") + 'egg' + >>> assert len(lfu) == 3 + >>> lfu.set("egg", "spam") + >>> assert len(lfu) == 3, str(len(lfu)) + " is not 3" + """ + + lock: RLock + usages: dllist + + __slots__ = 'lock', 'usages' + + def _on_init(self): + self.usages: dllist = dllist() + self.lock: RLock = RLock() + + def _on_overflow(self): + with self.lock: + while self._is_overflow(): + node: Optional[dllistnode] = self.usages.popleft() + + if node is None: + return + + item: Item = node.value + self.cache.pop(item.key, None) + + del item.node + del item.key + del item.value + + def _is_overflow(self) -> bool: + return len(self.cache) > self.max_size + + def get(self, key: Hashable): + item: dllistnode = self.cache[key] + + with self.lock: + self.usages.remove(item) + self.usages.appendright(item.value) + + return item.value.value + + def remove(self, key: Hashable): + with self.lock: + node: Optional[dllistnode] = self.cache.pop(key, None) + + if node is None: + return + + self.usages.remove(node) + print(node) + + def set(self, key: Hashable, value: Any): + with self.lock: + item = Item(node=None, key=key, value=value) + node = self.usages.appendright(item) + item.node = node + self.cache[key] = node + + if self._is_overflow(): + self._on_overflow() + + def __contains__(self, key: Hashable): + return key in self.cache + + def __len__(self): + return len(self.cache) diff --git a/modd.conf b/modd.conf new file mode 100644 index 00000000..e52b567f --- /dev/null +++ b/modd.conf @@ -0,0 +1,7 @@ +**/*.py { + prep: pytest -sx tests/cache/test_dllist.py --cov aiomisc.cache --cov-report=term-missing +} + +requirements.* { + prep: pip install -Ue ".[develop]" +} diff --git a/requirements.dev.txt b/requirements.dev.txt index 6631c80c..86fdbb0f 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,5 +1,5 @@ -aiohttp<4 aiohttp-asgi +aiohttp<4 async-generator async-timeout coverage==4.5.1 @@ -11,9 +11,10 @@ mypy~=0.782 pylava pytest pytest-cov~=2.5.1 +pytest-subtests pytest-freezegun~=0.4.2 -sphinx>=3.5.1 sphinx-autobuild sphinx-intl +sphinx>=3.5.1 timeout-decorator tox>=2.4 diff --git a/tests/cache/__init__.py b/tests/cache/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/cache/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/cache/test_dllist.py b/tests/cache/test_dllist.py new file mode 100644 index 00000000..de682bc5 --- /dev/null +++ b/tests/cache/test_dllist.py @@ -0,0 +1,144 @@ +from typing import List + +import pytest + +from aiomisc.cache.dllist import DLList, Node + + +def test_simple(subtests): + dllist = DLList() + node_class = DLList.NODE_CLASS + + with subtests.test("blank object"): + assert len(dllist) == 0 + assert dllist.first is None + assert dllist.last is None + + with subtests.test("one node"): + node1 = dllist.create_right() + assert isinstance(node1, node_class) + assert len(dllist) == 1 + assert dllist.first is node1 + assert dllist.last is node1 + assert node1.next is None + assert node1.prev is None + + with subtests.test("two nodes"): + node2 = dllist.create_right() + assert isinstance(node2, node_class) + assert dllist.first is node1 + assert dllist.last is node2 + assert node1.next is node2 + assert dllist.first.next is node2 + + with subtests.test("three nodes"): + node3 = dllist.create_left() + assert isinstance(node3, node_class) + assert dllist.first is node3 + assert dllist.first.next is node1 + + assert dllist.last is node2 + assert node3.next is node1 + assert node1.next is node2 + assert node2.prev is node1 + assert node1.prev is node3 + assert dllist.last is node2 + + with subtests.test("remove node"): + first = dllist.first + while first: + with subtests.test("remove first"): + dllist.remove(dllist.first) + assert dllist.first is not first + assert first not in dllist + first = dllist.first + + +@pytest.fixture +def nodes() -> list: + return [] + + +@pytest.fixture +def dllist(nodes): + dllist = DLList() + for i in range(10): + nodes.append(dllist.create_right()) + + return dllist + + +ITERATIONS = 10 + + +@pytest.mark.parametrize("node_idx", list(range(ITERATIONS))) +def test_remove(node_idx: int, nodes: List[Node], dllist: DLList): + node = nodes.pop(node_idx) + assert node in dllist + dllist.remove(node) + assert node not in dllist + + for idx, item in enumerate(dllist): + assert nodes[idx] is item + + +def test_remove_first(subtests, nodes: List[Node], dllist: DLList): + first = dllist.first + counter = 0 + while first is not None: + with subtests.test(f"iteration={counter}"): + counter += 1 + dllist.remove(dllist.first) + assert dllist.first is not first + assert first not in dllist + first = dllist.first + + assert len(dllist) == (ITERATIONS - counter) + for idx, item in enumerate(dllist): + assert nodes[counter:][idx] is item + + +def test_remove_last(subtests, nodes: List[Node], dllist: DLList): + last = dllist.last + counter = 0 + while last is not None: + with subtests.test(f"iteration={counter}"): + counter += 1 + dllist.remove(dllist.last) + assert dllist.last is not last + assert last not in dllist + last = dllist.last + + assert len(dllist) == (ITERATIONS - counter) + + for idx, item in enumerate(dllist): + assert nodes[idx] is item + + +def test_node_repr_recursion(nodes: List[Node]): + for node in nodes: + assert str(id(node)) in repr(node) + assert str(id(node.next)) in repr(node) + assert str(id(node.prev)) in repr(node) + + +def test_node_swap(dllist): + a, b = dllist.first, dllist.last + a_next = a.next + a_prev = a.prev + b_next = b.next + b_prev = b.prev + + assert dllist.first is a + assert dllist.last is b + + dllist.first.swap(dllist.last) + + assert dllist.first is not a + assert dllist.last is not b + + assert b_next is not b.next + assert a_next is not a.next + assert a_prev is not a.prev + assert b_prev is not b.prev + diff --git a/tests/cache/test_lfu.py b/tests/cache/test_lfu.py new file mode 100644 index 00000000..6abde0d6 --- /dev/null +++ b/tests/cache/test_lfu.py @@ -0,0 +1,34 @@ +from aiomisc.cache.lfu import LFUCachePolicy + + +def test_simple(): + test_data = [(chr(i), i) for i in range(97, 127)] + + lfu = LFUCachePolicy(max_size=10) + + for key, value in test_data[:10]: + lfu.set(key, value) + + lfu.get(key) + + assert key in lfu + + assert len(lfu) == 10 + + lfu.set("foo", "bar") + + assert len(lfu) == 10 + + lfu.remove("foo") + + for key, value in test_data[:10]: + lfu.remove(key) + assert key not in lfu + + assert len(lfu) == 0 + assert len(lfu.cache) == 0 + + node = lfu.usages + while node is not None: + assert len(node.items) == 0 + node = node.next