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
+184 -20
View File
@@ -1,37 +1,73 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from typing import cast
import pytest
from hooked_containers import events as e
from hooked_containers.mapping import HookedMapping
from hooked_containers.mixins import ContainerMixin
from hooked_containers.mixins import ListMixin
from hooked_containers.mixins import MutableMappingMixin
from hooked_containers.mixins import MutableSetMixin
@pytest.mark.parametrize(
("mixin", "storage", "method", "arguments", "expected"),
[
(ListMixin, [1], "append", (2,), e.Change("add", 1, 2)),
(MutableMappingMixin, {"a": 1}, "update", ({"b": 2},), e.Change("add", "b", 2)),
(MutableSetMixin, {1}, "add", (2,), e.Change("add", None, 2)),
],
)
def test_direct_mixin_composition(mixin, storage, method, arguments, expected):
custom_type = type("CustomContainer", (mixin,), {})
container = custom_type()
changes = []
container._storage = storage.copy()
container.hook = changes.append
assert isinstance(container, ContainerMixin)
assert len(container) == 1
assert list(container) == list(storage)
assert next(iter(storage)) in container
assert repr(container) == f"CustomContainer({storage!r})"
assert changes == []
getattr(container, method)(*arguments)
assert changes == [expected]
assert len(container) == 2
assert len(storage) == 1
@dataclass
class HookTracker:
added: list[e.AddItemEvent] = field(default_factory=list)
set: list[e.SetItemEvent] = field(default_factory=list)
removed: list[e.RemoveItemEvent] = field(default_factory=list)
added: list[object] = field(default_factory=list)
set: list[object] = field(default_factory=list)
removed: list[object] = field(default_factory=list)
def hook(self, event: e.ChangeEvent):
match event:
case e.AddItemEvent():
self.added.append(event.item)
case e.SetItemEvent():
self.set.append(event.item)
case e.RemoveItemEvent():
self.removed.append(event.item)
def hook(self, event: e.Change):
match event.operation:
case "add":
self.added.append(event.value)
case "set":
self.set.append(event.value)
case "remove":
self.removed.append(event.value)
class TestHookedMapping:
class TestConstruction:
def test_empty(self):
m = HookedMapping({})
assert m._data == {}
assert m._storage == {}
assert dict(m) == {}
def test_with_existing(self):
existing = {"a": 1, "b": 2}
og_id = id(existing)
m = HookedMapping(existing)
assert id(m._data) == og_id
assert m._storage is not existing
assert dict(m) == existing
def test_nesting(self):
@@ -41,20 +77,32 @@ class TestHookedMapping:
assert dict(m["a"]) == {"x": 1}
assert dict(m["b"]) == {"y": 2}
def test_recreation_copies_storage_without_duplicate_hooks(self):
original_tracker = HookTracker()
tracker = HookTracker()
original = HookedMapping({"a": 1}, original_tracker.hook)
m = HookedMapping(original, tracker.hook)
assert m._storage is not original._storage
m["a"] = 2
assert original._storage == {"a": 1}
assert tracker.set == [2]
assert original_tracker.set == []
class TestMappingOps:
def test_setitem(self):
tracker = HookTracker()
m = HookedMapping({}, tracker.hook)
m["a"] = 1
assert m._data == {"a": 1}
assert m._storage == {"a": 1}
assert tracker.added == [1]
def test_nested_setitem(self):
def test_nested_values_are_not_observed(self):
tracker = HookTracker()
m = HookedMapping({"a": {}}, tracker.hook)
m["a"]["x"] = 1
assert m._data == {"a": {"x": 1}}
assert tracker.added == [1]
assert m._storage == {"a": {"x": 1}}
assert tracker.added == []
def test_getitem(self):
m = HookedMapping({"a": 1})
@@ -64,5 +112,121 @@ class TestHookedMapping:
tracker = HookTracker()
m = HookedMapping({"a": 1}, tracker.hook)
del m["a"]
assert not m["a"]
with pytest.raises(KeyError):
m["a"]
assert tracker.removed == [1]
def test_inherited_mutation_helpers(self):
tracker = HookTracker()
m = HookedMapping({"a": 1}, tracker.hook)
m.update({"a": 2, "b": 3})
assert tracker.set == [2]
assert tracker.added == [3]
assert m.pop("b") == 3
assert tracker.removed == [3]
assert m._storage == {"a": 2}
def test_assignment_returns_none_and_reports_key(self):
changes = []
m = HookedMapping({"a": 1}, changes.append)
assert m.__setitem__("a", 2) is None
assert m.__setitem__("b", 3) is None
assert changes == [e.Change("set", "a", 2), e.Change("add", "b", 3)]
def test_assign_hooked_mapping(self):
changes = []
m = HookedMapping({}, changes.append)
value = HookedMapping({"x": 1})
assert m.__setitem__("a", value) is None
assert m["a"] is value
assert len(changes) == 1
assert changes[0].operation == "add"
assert changes[0].value is value
def test_shallow_value_identity(self):
changes = []
m = HookedMapping({"a": {"x": 1}}, changes.append)
m["a"]["x"] = 2
del m["a"]["x"]
assert changes == []
value = m["a"]
del m["a"]
assert changes == [e.Change("remove", "a", {})]
assert changes[0].value is value
def test_reads_views_and_missing_keys_do_not_mutate(self):
changes = []
mapping = HookedMapping({"a": 1}, changes.append)
keys = mapping.keys()
assert mapping.get("missing", 9) == 9
assert mapping.pop("missing", 9) == 9
with pytest.raises(KeyError):
mapping["missing"]
with pytest.raises(KeyError):
del mapping["missing"]
assert changes == []
assert list(mapping.items()) == [("a", 1)]
mapping["b"] = 2
assert list(keys) == ["a", "b"]
assert list(mapping.values()) == [1, 2]
assert list(reversed(mapping)) == ["b", "a"]
def test_native_order_copy_and_merge(self):
changes = []
mapping = HookedMapping([("a", 1), ("b", 2)], changes.append, c=3)
assert mapping == {"a": 1, "b": 2, "c": 3}
assert mapping.popitem() == ("c", 3)
assert changes == [e.Change("remove", "c", 3)]
changes.clear()
assert type(mapping.copy()) is dict
assert mapping | {"a": 9} == {"a": 9, "b": 2}
assert {"a": 9} | mapping == {"a": 1, "b": 2}
assert changes == []
original = mapping
mapping |= [("b", 4), ("c", 5)]
assert mapping is original
assert changes == [e.Change("set", "b", 4), e.Change("add", "c", 5)]
def test_update_duck_mapping_and_partial_failure(self):
class KeysOnly:
def keys(self):
return ["a"]
def __getitem__(self, _key):
return 1
changes = []
mapping = HookedMapping(hook=changes.append)
mapping.update(KeysOnly())
with pytest.raises(ValueError):
mapping.update(cast(Any, [("b", 2), ("bad",)]))
assert mapping == {"a": 1, "b": 2}
assert changes == [e.Change("add", "a", 1), e.Change("add", "b", 2)]
def test_clear_and_setdefault(self):
changes = []
mapping = HookedMapping[str | None, int]({None: 1}, changes.append)
assert mapping.setdefault(None, 9) == 1
assert changes == []
assert mapping.setdefault("a", 2) == 2
assert changes == [e.Change("add", "a", 2)]
changes.clear()
mapping.clear()
assert mapping == {}
assert changes == [e.Change("remove", "a", 2), e.Change("remove", None, 1)]
changes.clear()
mapping.clear()
assert changes == []
def test_callback_error_leaves_write_in_place(self):
def fail(_change):
raise RuntimeError("callback failed")
mapping = HookedMapping(hook=fail)
with pytest.raises(RuntimeError, match="callback failed"):
mapping["a"] = 1
assert mapping == {"a": 1}
+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__
+184
View File
@@ -0,0 +1,184 @@
from dataclasses import FrozenInstanceError
from typing import Any
from typing import cast
import pytest
from hooked_containers import Change
from hooked_containers import HookedSet
class TestHookedSet:
def test_construction_and_owned_storage(self):
changes = []
original = {1, 2}
members = HookedSet(original, changes.append)
assert members == original
assert members._storage is not original
assert HookedSet([1, 1, 2]) == {1, 2}
assert HookedSet() == set()
recreated = HookedSet(members)
assert recreated._storage is not members._storage
assert changes == []
members.add(3)
assert original == {1, 2}
assert recreated == {1, 2}
def test_add_discard_and_no_ops(self):
changes = []
members = HookedSet[str](hook=changes.append)
members.add("a")
members.add("a")
members.discard("missing")
members.discard("a")
assert changes == [Change("add", None, "a"), Change("remove", None, "a")]
changes.clear()
members.clear()
members.update([])
members.difference_update([])
assert changes == []
def test_remove_pop_and_clear(self):
changes = []
members = HookedSet([1, 2], changes.append)
members.remove(1)
assert members.pop() == 2
assert changes == [Change("remove", None, 1), Change("remove", None, 2)]
changes.clear()
with pytest.raises(KeyError):
members.remove(3)
with pytest.raises(KeyError):
members.pop()
assert changes == []
members.update([3, 4])
changes.clear()
members.clear()
assert members == set()
assert {change.value for change in changes} == {3, 4}
assert all(change.operation == "remove" and change.key is None for change in changes)
def test_removal_reports_stored_value(self):
changes = []
members = HookedSet([1], changes.append)
members.discard(True)
assert changes == [Change("remove", None, 1)]
assert type(changes[0].value) is int
def test_hashability_is_validated_before_no_op_detection(self):
changes = []
members = HookedSet([frozenset({1})], changes.append)
with pytest.raises(TypeError):
members.add(cast(Any, {1}))
assert members == {frozenset({1})}
assert changes == []
members.discard(cast(Any, {1}))
assert members == set()
assert changes == [Change("remove", None, frozenset({1}))]
def test_algebra_and_named_helpers_do_not_notify(self):
changes = []
members = HookedSet([1, 2], changes.append)
assert members == {1, 2}
assert members <= {1, 2, 3}
assert members < {1, 2, 3}
assert members >= {1}
assert members > {1}
assert members != {1}
assert members.isdisjoint([3, 4])
assert members.issubset([1, 2, 3])
assert members.issuperset([1])
results = [
(members | {2, 3}, {1, 2, 3}),
(members & {2, 3}, {2}),
(members - {2, 3}, {1}),
(members ^ {2, 3}, {1, 3}),
({3} | members, {1, 2, 3}),
({2, 3} & members, {2}),
({2, 3} - members, {3}),
({2, 3} ^ members, {1, 3}),
(members.union([3], [4]), {1, 2, 3, 4}),
(members.intersection([2, 3]), {2}),
(members.difference([2], [3]), {1}),
(members.symmetric_difference([2, 3]), {1, 3}),
(members.copy(), {1, 2}),
]
for result, expected in results:
assert result == expected
assert type(result) is set
result.add(99)
assert members == {1, 2}
assert changes == []
def test_abc_operators_accept_iterables(self):
members = HookedSet([1, 2])
assert members | cast(Any, [3]) == {1, 2, 3}
assert members & cast(Any, [2]) == {2}
members |= cast(Any, [3])
assert members == {1, 2, 3}
@pytest.mark.parametrize("operation", ["union", "intersection", "difference", "symmetric_difference"])
def test_in_place_operations_keep_identity_and_notify(self, operation):
changes = []
members = HookedSet([1, 2], changes.append)
original = members
if operation == "union":
members |= {2, 3}
expected = {1, 2, 3}
expected_changes = {("add", 3)}
elif operation == "intersection":
members &= {2, 3}
expected = {2}
expected_changes = {("remove", 1)}
elif operation == "difference":
members -= {2, 3}
expected = {1}
expected_changes = {("remove", 2)}
else:
members ^= {2, 3}
expected = {1, 3}
expected_changes = {("remove", 2), ("add", 3)}
assert members is original
assert members == expected
assert {(change.operation, change.value) for change in changes} == expected_changes
assert all(change.key is None for change in changes)
def test_named_updates_and_self_aliases(self):
members = HookedSet([1, 2])
members.update([2, 3], [4])
assert members == {1, 2, 3, 4}
members.intersection_update([2, 3, 4], [3, 4])
assert members == {3, 4}
members.symmetric_difference_update([4, 5, 5])
assert members == {3, 5}
members.difference_update([3], [9])
assert members == {5}
members.update(members)
members.intersection_update(members)
assert members == {5}
members.symmetric_difference_update(members)
assert members == set()
members.update([1, 2])
members.difference_update(members)
assert members == set()
def test_partial_update_failure_is_observed(self):
def values():
yield 1
raise RuntimeError("iteration failed")
changes = []
members = HookedSet[int](hook=changes.append)
with pytest.raises(RuntimeError, match="iteration failed"):
members.update(values())
assert members == {1}
assert changes == [Change("add", None, 1)]
def test_change_metadata_is_immutable_and_values_are_shallow():
value = []
change = Change("set", "key", value)
with pytest.raises(FrozenInstanceError):
cast(Any, change).key = "other"
value.append(1)
assert change.value is value
assert change.value == [1]