restructured mixins

This commit is contained in:
John Lancaster
2026-10-10 09:44:47 -05:00
parent cbc7e784dc
commit 23d3c113d8
15 changed files with 2127 additions and 379 deletions
+222 -10
View File
@@ -1,12 +1,20 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import MutableMapping
from collections.abc import MutableSequence
from collections.abc import MutableSet
from typing import Any
import pytest
from hooked_containers import events as e
from hooked_containers.mapping import HookedMapping
from hooked_containers.mixins import ListMixin
from hooked_containers.mixins import MutableMappingMixin
from hooked_containers.mixins import MutableSetMixin
from hooked_containers.sequence import HookedList
def get_id(a: Any):
return str(hex(id(a)))
from hooked_containers.set import HookedSet
class TestHookedList:
@@ -21,13 +29,26 @@ class TestHookedList:
def test_recreation(self):
initial = [1, 2, 3]
initial_id = id(initial)
lst = HookedList(initial)
assert id(lst._data) == initial_id
assert lst._storage is not initial
lst2 = HookedList(lst)
assert id(lst2._data) == initial_id
assert lst2._storage is not lst._storage
assert lst2 == initial
def test_recreation_does_not_duplicate_hooks(self):
original_changes = []
changes = []
original = HookedList([1], original_changes.append)
lst = HookedList(original, changes.append)
lst.append(2)
assert lst._storage is not original._storage
assert original._storage == [1]
assert original_changes == []
assert len(changes) == 1
assert changes == [e.Change("add", 1, 2)]
class TestSeqOps:
def test_len(self):
@@ -53,16 +74,16 @@ class TestHookedList:
class TestMutableOps:
def test_setitem(self):
added = []
lst = HookedList(
lst = HookedList[Any](
[1, 2, [4, 5, [6, 7]]],
lambda e: added.append(e.item),
lambda event: added.append(event.value),
)
lst[0] = 10
lst.append(20)
assert added == [10, 20]
lst[2][-1].append(8)
assert added == [10, 20, 8]
assert added == [10, 20]
def test_delitem(self):
lst = HookedList([1, 2, 3])
@@ -73,3 +94,194 @@ class TestHookedList:
lst = HookedList([1, 3])
lst.insert(1, 2)
assert list(lst) == [1, 2, 3]
def test_inherited_mutation_helpers(self):
changes = []
lst = HookedList([1], changes.append)
lst.extend([2, 3])
assert lst.pop() == 3
lst.remove(1)
assert list(lst) == [2]
assert changes == [
e.Change("add", 1, 2),
e.Change("add", 2, 3),
e.Change("remove", 2, 3),
e.Change("remove", 0, 1),
]
def test_slice_mutations(self):
changes = []
lst = HookedList([1, 2, 3], changes.append)
lst[1:3] = iter([4, 5])
assert list(lst) == [1, 4, 5]
del lst[::2]
assert list(lst) == [4]
assert changes == [
e.Change("set", slice(1, 3), [4, 5]),
e.Change("remove", slice(None, None, 2), [1, 5]),
]
def test_failed_mutations_do_not_notify(self):
changes = []
lst = HookedList([1, 2, 3], changes.append)
with pytest.raises(ValueError):
lst[::2] = [4]
with pytest.raises(IndexError):
del lst[10]
assert list(lst) == [1, 2, 3]
assert changes == []
def test_nested_values_are_not_observed(self):
changes = []
lst = HookedList([[1]], changes.append)
nested = lst[0]
nested[0] = 2
nested.insert(1, 3)
del nested[0]
assert lst._storage == [[3]]
assert changes == []
def test_native_comparisons_and_non_mutating_results(self):
changes = []
items = HookedList([1, 2], changes.append)
assert items == [1, 2]
native = [1, 2]
assert native == items
assert items != (1, 2)
assert items < [1, 3]
assert items <= HookedList([1, 2])
assert items > [0]
assert items >= [1, 2]
with pytest.raises(TypeError):
assert items < (1, 3)
with pytest.raises(TypeError):
_ = items + (3,)
for result in (items[:], items.copy(), items + [3], [0] + items, items * 2, 2 * items):
assert type(result) is list
result.append(99)
assert items == [1, 2]
assert changes == []
def test_index_protocol_and_normalized_indices(self):
class Index:
def __index__(self):
return -1
changes = []
items = HookedList([1, 2], changes.append)
assert items[Index()] == 2
items[Index()] = 3
items.insert(-100, 0)
items.insert(100, 4)
del items[Index()]
assert items == [0, 1, 3]
assert changes == [
e.Change("set", 1, 3),
e.Change("add", 0, 0),
e.Change("add", 3, 4),
e.Change("remove", 3, 4),
]
def test_self_extension_and_in_place_repetition(self):
changes = []
items = HookedList([1, 2], changes.append)
original = items
items += items
assert items is original
assert items == [1, 2, 1, 2]
assert changes == [e.Change("add", 2, 1), e.Change("add", 3, 2)]
changes.clear()
items *= 2
assert items is original
assert items == [1, 2] * 4
assert changes == [e.Change("set", slice(None), [1, 2] * 4)]
changes.clear()
items *= 1
assert changes == []
items *= 0
assert items == []
assert changes == [e.Change("set", slice(None), [])]
def test_reverse_sort_and_empty_writes(self):
changes = []
items = HookedList([3, 1, 2], changes.append)
items.reverse()
assert items == [2, 1, 3]
assert changes == [e.Change("set", 0, 2), e.Change("set", 2, 3)]
changes.clear()
items.sort(reverse=True)
assert items == [3, 2, 1]
assert changes == [e.Change("set", slice(None), [3, 2, 1])]
changes.clear()
del items[0:0]
items[0:0] = []
items.extend([])
assert changes == []
items.clear()
assert [change.value for change in changes] == [1, 2, 3]
changes.clear()
items.clear()
items.reverse()
items.sort()
assert changes == []
def test_partial_extend_and_sort_failure(self):
def values():
yield 2
raise RuntimeError("iteration failed")
changes = []
items = HookedList([1], changes.append)
with pytest.raises(RuntimeError, match="iteration failed"):
items.extend(values())
assert items == [1, 2]
assert changes == [e.Change("add", 1, 2)]
changes.clear()
def fail(_value):
raise ValueError("key failed")
with pytest.raises(ValueError, match="key failed"):
items.sort(key=fail)
assert items == [1, 2]
assert changes == []
@pytest.mark.parametrize(
("mixin", "factory", "mutate", "expected"),
[
(MutableMappingMixin, dict, lambda container: container.update(a=1), e.Change("add", "a", 1)),
(ListMixin, list, lambda container: container.append(1), e.Change("add", 0, 1)),
(MutableSetMixin, set, lambda container: container.add(1), e.Change("add", None, 1)),
],
)
def test_mixins_work_without_concrete_wrappers(mixin, factory, mutate, expected):
class Custom(mixin):
def __init__(self):
self._storage = factory()
changes = []
container = Custom()
container.hook = changes.append
mutate(container)
assert changes == [expected]
def test_helpers_are_inherited_from_standard_abcs():
assert HookedList.append is MutableSequence.append
assert HookedList.extend is MutableSequence.extend
assert HookedList.reverse is MutableSequence.reverse
assert HookedList.__iadd__ is MutableSequence.__iadd__
assert HookedMapping.get is Mapping.get
assert HookedMapping.keys is Mapping.keys
assert HookedMapping.update is MutableMapping.update
assert HookedSet.pop is MutableSet.pop
assert HookedSet.remove is MutableSet.remove
assert HookedSet.__ior__ is MutableSet.__ior__
assert HookedSet.__iand__ is MutableSet.__iand__
assert HookedSet.__ixor__ is MutableSet.__ixor__
assert HookedSet.__isub__ is MutableSet.__isub__