diff --git a/src/dopt_basics/batching.py b/src/dopt_basics/batching.py new file mode 100644 index 0000000..5163b97 --- /dev/null +++ b/src/dopt_basics/batching.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from collections.abc import Collection +from typing import Generic, Never, TypeVar + +T = TypeVar("T") + + +class Chunk(Generic[T]): + def __init__( + self, + capacity: int, + overfill_once: bool = True, + cap_property: str | None = None, + ) -> None: + self.capacity = capacity + self.overfill = overfill_once + self.capacity_left: int = capacity + self.capacity_held: int = 0 + self.full: bool = False + self._contents: list[T] = [] + self.cap_property = cap_property + + def __repr__(self) -> str: + return self._contents.__repr__() + + def __str__(self) -> str: + return self._contents.__str__() + + @property + def contents(self) -> list[T]: + return self._contents + + def __len__(self) -> int: + return len(self._contents) + + def __getitem__( + self, + idx: int, + ) -> T: + return self._contents[idx] + + def __raise_for_exceeded_capacity(self) -> Never: + raise ValueError("Item can not be added due to capacity limit.") + + def _capacity_sufficient( + self, + size_to_add: int, + ) -> bool: + if self.full: + return False + + new_size = len(self) + size_to_add + + if self.overfill and (new_size > self.capacity): + return True + elif new_size > self.capacity: + return False + else: + return True + + def _recalc_capacity( + self, + size_to_add: int, + ) -> None: + self.capacity_held += size_to_add + self.capacity_left = max(self.capacity_left - size_to_add, 0) + if self.capacity_left == 0: + self.full = True + else: + self.full = False + + def append( + self, + object_: T, + ) -> None: + size_to_add: int = 1 + if self.cap_property is not None: + if not hasattr(object_, self.cap_property): + raise AttributeError("Object does not posses the wanted property") + attr = getattr(object_, self.cap_property) + if not isinstance(attr, int): + raise TypeError("Capacity property must be an integer") + size_to_add = attr + + if self._capacity_sufficient(size_to_add): + self._contents.append(object_) + self._recalc_capacity(size_to_add) + else: + self.__raise_for_exceeded_capacity() + + def extend( + self, + collection: Collection[T], + ) -> None: + size_to_add: int = len(collection) + if self.cap_property is not None: + if not all(hasattr(object_, self.cap_property) for object_ in collection): + raise AttributeError("Not all objects posses the wanted property") + if not all( + isinstance(getattr(object_, self.cap_property), int) for object_ in collection + ): + raise TypeError("Capacity property must be an integer") + size_to_add = sum(getattr(object_, self.cap_property) for object_ in collection) + + if self._capacity_sufficient(size_to_add): + self._contents.extend(collection) + self._recalc_capacity(size_to_add) + else: + self.__raise_for_exceeded_capacity() + + def remove( + self, + object_: T, + ) -> None: + size_to_add: int = 1 + if self.cap_property is not None: + if not hasattr(object_, self.cap_property): + raise AttributeError("Object does not posses the wanted property") + attr = getattr(object_, self.cap_property) + if not isinstance(attr, int): + raise TypeError("Capacity property must be an integer") + size_to_add = attr + size_to_remove = (-1) * size_to_add + + self._contents.remove(object_) + self._recalc_capacity(size_to_remove) diff --git a/tests/test_batching.py b/tests/test_batching.py new file mode 100644 index 0000000..7464ef0 --- /dev/null +++ b/tests/test_batching.py @@ -0,0 +1,142 @@ +import pytest + +from dopt_basics.batching import Chunk + + +class _Item: + def __init__( + self, + size: int, + ) -> None: + self.size = size + + +def test_Chunk_base(): + chunk: Chunk[int] = Chunk(3) + + assert chunk.capacity == 3 + assert chunk.capacity_held == 0 + assert chunk.capacity_left == 3 + assert chunk.full is False + assert len(chunk) == 0 + assert not chunk.contents + + +# TODO implement behaviour +# def test_Chunk_base_overfill_append(): +# chunk: Chunk[int] = Chunk(3) + +# chunk.append(1) +# chunk.append(2) +# chunk.append(3) +# chunk.append(4) # allow once, then not again + + +def test_Chunk_base_no_overfill_append(): + chunk: Chunk[int] = Chunk(3, overfill_once=False) + + chunk.append(1) + assert chunk[0] == 1 + chunk.append(2) + chunk.append(3) + with pytest.raises(ValueError, match="Item can not be added due to capacity limit"): + chunk.append(4) + + +def test_Chunk_base_overfill_extend(): + chunk: Chunk[int] = Chunk(3) + + chunk.extend((1, 2)) + chunk.extend((3, 4)) + + with pytest.raises(ValueError, match="Item can not be added due to capacity limit"): + chunk.extend((5, 6)) + + +def test_Chunk_base_no_overfill_extend(): + chunk: Chunk[int] = Chunk(3, overfill_once=False) + + chunk.extend((1, 2)) + + with pytest.raises(ValueError, match="Item can not be added due to capacity limit"): + chunk.extend((3, 4)) + + +def test_Chunk_base_remove(): + chunk: Chunk[int] = Chunk(3, overfill_once=False) + + chunk.extend((1, 2)) + assert chunk.capacity_held == 2 + assert chunk.capacity_left == 1 + + chunk.remove(2) + assert chunk.capacity_held == 1 + assert chunk.capacity_left == 2 + + +def test_Chunk_property_append_success(): + CAP_PROP = "size" + chunk: Chunk[_Item] = Chunk(3, overfill_once=False, cap_property=CAP_PROP) + i1 = _Item(2) + i2 = _Item(4) + + chunk.append(i1) + + with pytest.raises(ValueError, match="Item can not be added due to capacity limit"): + chunk.append(i2) + + +def test_Chunk_property_append_fail_no_attribute(): + CAP_PROP = "size" + chunk: Chunk = Chunk(3, overfill_once=False, cap_property=CAP_PROP) + + with pytest.raises(AttributeError, match="Object does not posses the wanted property"): + chunk.append(1) + + +def test_Chunk_property_append_fail_wrong_type(): + CAP_PROP = "size" + chunk: Chunk = Chunk(3, overfill_once=False, cap_property=CAP_PROP) + i1 = _Item(4) + i1.size = "wrong type" # type: ignore + + with pytest.raises(TypeError, match="Capacity property must be an integer"): + chunk.append(i1) + + +def test_Chunk_property_extend_fail_no_attribute(): + CAP_PROP = "size" + chunk: Chunk = Chunk(3, overfill_once=False, cap_property=CAP_PROP) + i1 = _Item(2) + + with pytest.raises(AttributeError, match="Not all objects posses the wanted property"): + chunk.extend((i1, 2)) + + +def test_Chunk_property_extend_fail_wrong_type(): + CAP_PROP = "size" + chunk: Chunk = Chunk(3, overfill_once=False, cap_property=CAP_PROP) + i1 = _Item(2) + i2 = _Item(4) + i2.size = "wrong type" # type: ignore + + with pytest.raises(TypeError, match="Capacity property must be an integer"): + chunk.extend((i1, i2)) + + +def test_Chunk_property_remove_fail_no_attribute(): + CAP_PROP = "size" + chunk: Chunk = Chunk(3, overfill_once=False, cap_property=CAP_PROP) + + with pytest.raises(AttributeError, match="Object does not posses the wanted property"): + chunk.remove(1) + + +def test_Chunk_property_remove_fail_wrong_type(): + CAP_PROP = "size" + chunk: Chunk = Chunk(3, overfill_once=False, cap_property=CAP_PROP) + i1 = _Item(4) + i1.size = "wrong type" # type: ignore + + with pytest.raises(TypeError, match="Capacity property must be an integer"): + chunk.remove(i1)