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
+52 -25
View File
@@ -1,49 +1,76 @@
# Hooked Containers
Hooked Containers provides an abstract container foundation that calls a function hook whenever its contents are changed through its API. The aim is to retain familiar Python container operations while letting application code react to additions, replacements, and removals.
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
The design follows Python's [collections.abc abstract base classes](https://docs.python.org/3/library/collections.abc.html#collections-abstract-base-classes). Separate mixin classes should build up functionality in layers, so a custom container can compose the behavior it needs:
All reusable container behavior lives in `src/hooked_containers/mixins.py`. The design uses Python's [collections.abc abstract base classes](https://docs.python.org/3/library/collections.abc.html#collections-abstract-base-classes) directly. Three mutable mixins compose shared reads and hook dispatch with their required primitives; standard ABC methods supply the helpers:
| Layer | Responsibility |
| ---------------- | ------------------------------------------------------------------------------------------- |
| Container | Membership testing through `__contains__`. |
| Collection | Iteration and size through `__iter__` and `__len__`, alongside membership. |
| Hook support | A shared callback and change-event reporting for mutation methods. |
| Sequence | Indexed access through `__getitem__`, with sequence helpers such as `index` and `count`. |
| Mutable sequence | Assignment, deletion, and insertion, supporting helpers such as `append`, `extend`, and `pop`. |
| Mapping | Key-based access, iteration over keys, and helpers such as `keys`, `items`, and `values`. |
| Mutable mapping | Assignment and deletion, supporting helpers such as `update`, `setdefault`, and `pop`. |
| 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. |
Sequences and mappings are separate branches built on the common container foundation, not successive steps in a single inheritance chain. Mutation hooks belong in the primitive operations so inherited ABC mixin methods also report changes. A compound operation may therefore invoke the hook more than once.
`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.
This layered separation is the intended architecture. Currently, `HookedContainer` combines shared container and hook behavior; `HookedList` and `HookedMapping` extend it with the standard library's `MutableSequence` and `MutableMapping` mixins, respectively.
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:
```python
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 change-event object after a mutation. The event identifies the root container (`root`), the index or key path (`path`), and the affected value (`item`). The current event types are `AddItemEvent`, `SetItemEvent`, and `RemoveItemEvent`.
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 |
```python
from hooked_containers.mapping import HookedMapping
from hooked_containers.sequence import HookedList
from hooked_containers import HookedList, HookedMapping, HookedSet
def on_change(event):
print(type(event).__name__, event.path, event.item)
def on_change(change):
print(change.operation, change.key, change.value)
items = HookedList(["first"], hook=on_change)
items.append("second") # AddItemEvent (1,) second
items[0] = "updated" # SetItemEvent (0,) updated
del items[1] # RemoveItemEvent (1,) second
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 # AddItemEvent ('enabled',) True
settings["enabled"] = True # add enabled True
members = HookedSet(hook=on_change)
members.add("ready") # add None ready
members.discard("ready") # remove None ready
```
The wrappers can share existing mutable storage. Mutating that storage directly bypasses the hook; make changes through the hooked container to receive events. Hooks are optional, and nested wrappers retain the root and path for supported nested container types.
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)`.
## Status
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.
Requires Python 3.12 or newer. This repository is an early implementation of the container and mixin design; the API and separation of responsibilities may evolve.
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.