Files
hooked-containers/README.md
T
2026-10-10 09:44:47 -05:00

5.5 KiB

Hooked Containers

Hooked Containers adds optional, shallow mutation callbacks to familiar Python container operations. It provides HookedMapping[K, V], HookedList[T], and HookedSet[T], backed by owned dict, list, and set storage. Requires Python 3.12 or newer.

Mixin Design

All reusable container behavior lives in src/hooked_containers/mixins.py. The design uses Python's collections.abc abstract base classes directly. Three mutable mixins compose shared reads and hook dispatch with their required primitives; standard ABC methods supply the helpers:

Mixin Backing Storage Responsibility
ContainerMixin Any collection Containment, iteration, length, and representation.
HookMixin None Callback dispatch after successful writes.
ListMixin list Indexed reads and writes, insertion, and native list operations.
MutableMappingMixin dict Key-based reads and writes, copying, and merging.
MutableSetMixin set Addition, discard, and named set operations.

HookMixin shares callback dispatch across the three mixins. Inherited methods such as append, extend, reverse, update, clear, and set in-place operators reach the primitive writes automatically. A compound operation may invoke the hook more than once.

Each subclass initializes a single _storage attribute with the corresponding native container. There is no storage property or separate container-specific read-only hierarchy. Each of sequence.py, mapping.py, and set.py defines just one concrete wrapper, composing its mutable mixin and adding construction. Representation comes from ContainerMixin. Dict-style popitem intentionally removes the most recently inserted key, unlike the ABC's arbitrary-item implementation.

Custom containers can use the mixins without any concrete wrapper:

from hooked_containers.mixins import MutableMappingMixin


class Settings(MutableMappingMixin[str, int]):
    def __init__(self, hook=None):
        self._storage = {}
        self.hook = hook

Change Hooks

A hook receives one immutable Change(operation, key, value) record after a write:

Field Meaning
operation "add", "set", or "remove"
key Mapping key, normalized list index, list slice, or None for sets
value Added/new value, or the value that was removed
from hooked_containers import HookedList, HookedMapping, HookedSet


def on_change(change):
    print(change.operation, change.key, change.value)


items = HookedList(["first"], hook=on_change)
items.append("second")       # add 1 second
items[0] = "updated"         # set 0 updated
del items[1]                 # remove 1 second

settings = HookedMapping({}, hook=on_change)
settings["enabled"] = True   # add enabled True

members = HookedSet(hook=on_change)
members.add("ready")         # add None ready
members.discard("ready")     # remove None ready

Reads never change data or invoke the callback. Missing mapping keys raise KeyError, and get respects its default. Integer list keys are normalized against the length before the write; insertions report their clamped destination. Slice keys preserve the supplied slice in pre-write coordinates, and their values are shallow lists of new or removed items. Sorting and in-place repetition report a whole-list "set" using slice(None).

Assignments notify even when assigning an equal value. Duplicate set additions, missing set discards, empty clears, and empty slice writes do not notify. Failed primitive writes do not notify; writes completed before a compound operation fails remain reported. A failed sort reports any resulting rearrangement. Callback exceptions propagate after the write without rolling it back. Callbacks that mutate the same container can trigger further callbacks; there is no batching or reentrancy guard.

Construction copies the outer storage without notifying. Nested values are not copied or wrapped, and mutations inside those values are not observed. Copies, slices, and non-mutating derived results are ordinary native containers without hooks. Event values are shallow references, not deep snapshots; later changes to a mutable value remain visible through that reference. Direct changes to private storage bypass callbacks.

Set comparison, algebra, and in-place methods follow the standard ABC implementations. In particular, ABC set operators accept iterables more broadly than built-in set operators. These wrappers implement container interfaces, not built-in subclass identity or exact built-in performance guarantees.

Migration

Custom subclasses now initialize _storage directly instead of implementing _data. The earlier container/collection hierarchy, read-only sequence, mapping, and set mixins, along with MutableSequenceMixin, have been replaced by the shared ContainerMixin and the three mutable mixins above. Import reusable mixins from hooked_containers.mixins and use native backing storage.

The shallow API replaces the earlier root/path event subclasses and recursive wrapping. Use change.operation, change.key, and change.value instead of event types, path, and item. HookedMapping has independent key and value type parameters. Shared outer storage, autovivification, suppress_hook, get_nested, and the domain-state classes are no longer supported. The development notebooks are historical experiments with the previous API; the examples above describe the current API.