Compare commits

..

25 Commits

Author SHA1 Message Date
John Lancaster
0567766b6c passing thru root reference 2026-02-22 21:47:11 -06:00
John Lancaster
66b4c1274f wrap_sub 2026-02-22 21:44:06 -06:00
John Lancaster
0c3dfde336 types module 2026-02-22 21:06:32 -06:00
John Lancaster
8eea62f403 added suppress option 2026-02-22 20:55:59 -06:00
John Lancaster
05ba036346 better new_path 2026-02-22 20:54:05 -06:00
John Lancaster
2efdf19ee1 set/add event 2026-02-22 19:04:00 -06:00
John Lancaster
977e87d7f5 hook tracker 2026-02-22 18:32:37 -06:00
John Lancaster
406024d990 asserting type 2026-02-22 18:28:33 -06:00
John Lancaster
0e58dc7a86 event fixes 2026-02-22 18:25:26 -06:00
John Lancaster
07bdbe0b46 changed events 2026-02-22 18:19:48 -06:00
John Lancaster
62204abdd0 default-ish behavior 2026-02-22 18:02:32 -06:00
John Lancaster
dcc8a37627 started mapping tests 2026-02-22 18:02:22 -06:00
John Lancaster
85bf9a431e import ergonomics 2026-02-22 17:48:52 -06:00
John Lancaster
70fa9ede75 working mapping? 2026-02-22 17:48:27 -06:00
John Lancaster
7f4ed3a4b2 update 2026-02-22 17:21:05 -06:00
John Lancaster
4efd57dfb7 rename 2026-02-22 17:21:01 -06:00
John Lancaster
7576bfb719 new_path method 2026-02-22 17:20:11 -06:00
John Lancaster
13f49ec6cb removed notebook 2026-02-22 17:17:55 -06:00
John Lancaster
570c14f977 rename 2026-02-22 09:17:16 -06:00
John Lancaster
2da2002cb9 reorder 2026-02-21 23:29:45 -06:00
John Lancaster
3281c7c1ea readability 2026-02-21 23:24:48 -06:00
John Lancaster
071c3bd342 reorg 2026-02-21 23:24:09 -06:00
John Lancaster
7c3e073c54 nested tests 2026-02-21 23:19:43 -06:00
John Lancaster
0980145b10 org 2026-02-21 23:19:32 -06:00
John Lancaster
941e689c19 get_nested 2026-02-21 23:19:23 -06:00
11 changed files with 237 additions and 105 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
*.ipynb

View File

@@ -1,19 +0,0 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"id": "e1b794f1",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@@ -1,2 +0,0 @@
def hello() -> str:
return "Hello from hooked-containers!"

View File

@@ -1,46 +1,62 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Container, Iterable, MutableMapping, MutableSequence, Sized from collections.abc import Container, Iterable, MutableMapping, MutableSequence, Sized
from typing import Protocol, TypeVar from typing import TypeVar
from . import events as e from . import events as e
from .events import HookFunction
from .types import MutableNesting
T = TypeVar("T") T = TypeVar("T")
type MutableNesting[T] = T | MutableSequence[T] | MutableMapping[T, MutableNesting[T]]
class HookFunction(Protocol):
def __call__(self, event: e.ChangeEvent[T]) -> None: ...
class HookedContainer(ABC, Sized, Iterable[T], Container[T]): class HookedContainer(ABC, Sized, Iterable[T], Container[T]):
_data: MutableNesting[T]
_path: MutableSequence[int] _path: MutableSequence[int]
_root: MutableMapping[T] | MutableSequence[T] | None = None
hook: HookFunction hook: HookFunction
def __repr__(self): def __repr__(self):
return f"{self.__class__.__name__}({self._data!r})" return f"{self.__class__.__name__}({self._data!r})"
def __len__(self):
return len(self._data)
def __iter__(self):
return iter(self._data)
def __contains__(self, x):
return x in self._data
# Sequence Methods # Sequence Methods
# __contains__, __iter__, __reversed__, index, and count # __contains__, __iter__, __reversed__, index, and count
@abstractmethod @abstractmethod
def __getitem__(self, key): ... def __getitem__(self, key): ...
def __len__(self):
return len(self._data)
# MutableSequence Methods # MutableSequence Methods
# append, reverse, extend, pop, remove, and __iadd__ # append, reverse, extend, pop, remove, and __iadd__
def __setitem__(self, s, value): def __setitem__(self, s, value):
self._data[s] = value self._data[s] = value
if self.hook: if self.hook:
self.hook(e.SetItemEvent(index=s, item=value, path=self._path)) self.hook(e.SetItemEvent(self._root, self.new_path(s), value))
def __delitem__(self, s): def __delitem__(self, s):
item = self._data.pop(s) item = self._data.pop(s)
if self.hook: if self.hook:
self.hook(e.RemoveItemEvent(index=s, item=item, path=self._path)) self.hook(e.RemoveItemEvent(self._root, self.new_path(s), item))
@abstractmethod # @abstractmethod
def insert(self, index, value): ... # def insert(self, index, value): ...
# Custom Methods
def new_path(self, key):
return (*self._path, key)
def get_nested(self, keys):
"""Recursively call __getitem__ with each key in the iterable."""
result = self
for key in keys:
result = result[key]
return result

View File

@@ -1,27 +1,33 @@
from __future__ import annotations
from collections.abc import MutableSequence from collections.abc import MutableSequence
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Generic, TypeVar from typing import TYPE_CHECKING, Generic, Protocol, TypeVar
if TYPE_CHECKING:
from .mapping import HookedMapping
T = TypeVar("T") T = TypeVar("T")
@dataclass(frozen=True) @dataclass(frozen=True)
class ChangeEvent(Generic[T]): class ChangeEvent(Generic[T]):
index: int root: HookedMapping[T] = field(repr=False)
item: T path: MutableSequence[int]
path: MutableSequence[int] | None = None
@dataclass(frozen=True)
class AddItemEvent(ChangeEvent[T]):
item: T item: T
@dataclass(frozen=True) @dataclass(frozen=True)
class SetItemEvent(ChangeEvent[T]): class AddItemEvent(ChangeEvent[T]): ...
item: T
@dataclass(frozen=True) @dataclass(frozen=True)
class RemoveItemEvent(ChangeEvent[T]): class SetItemEvent(ChangeEvent[T]): ...
item: T
@dataclass(frozen=True)
class RemoveItemEvent(ChangeEvent[T]): ...
class HookFunction(Protocol):
def __call__(self, event: ChangeEvent[T]) -> None: ...

View File

@@ -1,9 +1,9 @@
from collections.abc import MutableMapping, MutableSequence, Sequence from collections.abc import MutableMapping, MutableSequence
from copy import copy
from typing import TypeVar from typing import TypeVar
from .common import HookedContainer, MutableNesting from . import events as e
from .sequence import HookedList from .container import HookedContainer, MutableNesting
from .events import HookFunction
T = TypeVar("T") T = TypeVar("T")
@@ -13,39 +13,60 @@ class HookedMapping(HookedContainer[T], MutableMapping[T, MutableNesting[T]]):
def __init__( def __init__(
self, self,
hook,
existing: MutableMapping[T, MutableNesting[T]], existing: MutableMapping[T, MutableNesting[T]],
path: Sequence[int] | None = None, hook: HookFunction | None = None,
root: MutableMapping[T] | None = None,
path: MutableSequence[int] | None = None,
): ):
self.hook = hook match existing:
case HookedMapping(_data=seq):
self._data = seq
case MutableMapping() as seq:
self._data = seq
case _:
raise TypeError(f"Expected a mapping, got {type(existing)}")
# self._data = dict(it)
self._data = existing self._data = existing
self.hook = hook
self._root = root if root is not None else self
self._path = list(path) if path is not None else [] self._path = list(path) if path is not None else []
def __wrap_sub__(self, other, **kwargs):
return HookedMapping(other, hook=self.hook, root=self._root, **kwargs)
def __getitem__(self, key: T) -> MutableNesting[T]: def __getitem__(self, key: T) -> MutableNesting[T]:
if key not in self._data:
return self.__wrap_sub__(self.__setitem__(key, {}), path=self.new_path(key))
value = self._data[key] value = self._data[key]
match value: match value:
case MutableMapping() as mapping: case MutableMapping() as mapping:
new_path = copy(self._path) return self.__wrap_sub__(mapping, path=self.new_path(key))
new_path.append(key)
return type(self)(self.hook, existing=mapping, path=new_path)
case MutableSequence() as seq:
new_path = copy(self._path)
new_path.append(key)
return HookedList(self.hook, existing=seq, path=new_path)
case _ as item: case _ as item:
return item return item
def __setitem__(self, key: T, value: MutableNesting[T]) -> None: def __setitem__(
self._data[key] = value self,
if self.hook: key: T,
from . import events as e value: MutableNesting[T],
*,
self.hook(e.SetItemEvent(index=key, item=value)) suppress_hook: bool = False,
) -> MutableNesting[T] | None:
new_path = self.new_path(key)
event = (
e.SetItemEvent(self._root, new_path, value)
if key in self._data
else e.AddItemEvent(self._root, new_path, value)
)
match value:
case HookedMapping(_data=value):
self._data[key] = value
case _:
self._data[key] = value
if self.hook and not suppress_hook:
self.hook(event)
return value
def __delitem__(self, key: T) -> None: def __delitem__(self, key: T) -> None:
item = self._data[key] item = self._data.pop(key)
del self._data[key]
if self.hook: if self.hook:
from . import events as e self.hook(e.RemoveItemEvent(self._root, self.new_path(key), item))
self.hook(e.RemoveItemEvent(index=key, item=item))

View File

@@ -3,7 +3,9 @@ from copy import copy
from typing import TypeVar from typing import TypeVar
from . import events as e from . import events as e
from .common import HookedContainer, HookFunction from .container import HookedContainer
from .events import HookFunction
from .types import MutableNesting
T = TypeVar("T") T = TypeVar("T")
@@ -12,7 +14,13 @@ class HookedList(HookedContainer[T], MutableSequence[T]):
_data: MutableSequence[T] _data: MutableSequence[T]
path: MutableSequence[int] path: MutableSequence[int]
def __init__(self, hook: HookFunction, existing: MutableSequence[T], path: MutableSequence[int] | None = None): def __init__(
self,
existing: MutableSequence[T],
hook: HookFunction | None = None,
root: MutableNesting[T] | None = None,
path: MutableSequence[int] | None = None,
) -> None:
self.hook = hook self.hook = hook
match existing: match existing:
case HookedContainer(_data=seq): case HookedContainer(_data=seq):
@@ -21,22 +29,21 @@ class HookedList(HookedContainer[T], MutableSequence[T]):
self._data = seq self._data = seq
case _ as it: case _ as it:
self._data = list(it) self._data = list(it)
self._root = root if root is not None else self
self._path = list(path) if path is not None else [] self._path = list(path) if path is not None else []
def __getitem__(self, s): def __getitem__(self, s):
new_path = copy(self._path)
new_path.append(s)
match self._data[s]: match self._data[s]:
case MutableSequence() as seq:
new_path = copy(self._path)
new_path.append(s)
return type(self)(self.hook, existing=seq, path=new_path)
case HookedContainer(_data=seq): case HookedContainer(_data=seq):
new_path = copy(self._path) return type(self)(seq, self.hook, root=self._root, path=new_path)
new_path.append(s) case MutableSequence() as seq:
return type(self)(self.hook, existing=seq, path=new_path) return HookedList(seq, self.hook, root=self._root, path=new_path)
case _ as item: case _ as item:
return item return item
def insert(self, index, value): def insert(self, index, value):
self._data.insert(index, value) self._data.insert(index, value)
if self.hook: if self.hook:
self.hook(e.AddItemEvent(index=index, item=value, path=self._path)) self.hook(e.AddItemEvent(self._root, self.new_path(index), value))

View File

@@ -1,22 +1,43 @@
from collections.abc import MutableMapping
from datetime import datetime from datetime import datetime
from .mapping import HookedMapping from .mapping import HookedMapping
class DomainState(HookedMapping[str]): class EntityState(HookedMapping[str]):
def __setitem__(self, key, value): def __setitem__(self, key, value):
super().__setitem__(key, value) super().__setitem__(key, value)
super().__setitem__("last_changed", datetime.now()) super().__setitem__("last_changed", datetime.now(), suppress_hook=True)
class DomainState(HookedMapping[str]):
_data: MutableMapping[str, EntityState]
def __getitem__(self, key):
match super().__getitem__(key):
case HookedMapping(_data=val):
return EntityState(val, hook=self.hook, path=self.new_path(key))
case _ as val:
raise TypeError(f"Expected a mapping for domain state, got {type(val)}")
class NameSpaceState(HookedMapping[str]): class NameSpaceState(HookedMapping[str]):
_data: DomainState _data: MutableMapping[str, DomainState]
def __setitem__(self, key, value):
super().__setitem__(key, value)
# print("ns SetItem")
def __getitem__(self, key): def __getitem__(self, key):
val = super().__getitem__(key) match super().__getitem__(key):
# print("ns GetItem") case HookedMapping(_data=val):
return DomainState(self.hook, existing=val, path=self._path + [key]) return DomainState(val, hook=self.hook, path=self.new_path(key))
case _ as val:
raise TypeError(f"Expected a mapping for domain state, got {type(val)}")
class FullState(HookedMapping[str]):
_data: MutableMapping[str, NameSpaceState]
def __getitem__(self, key):
match super().__getitem__(key):
case HookedMapping(_data=val):
return NameSpaceState(val, hook=self.hook, path=self.new_path(key))
case _ as val:
raise TypeError(f"Expected a mapping for namespace state, got {type(val)}")

View File

@@ -0,0 +1,6 @@
from collections.abc import MutableMapping, MutableSequence
from typing import TypeVar
T = TypeVar("T")
type MutableNesting[T] = T | MutableSequence[T] | MutableMapping[T, MutableNesting[T]]

68
tests/test_mapping.py Normal file
View File

@@ -0,0 +1,68 @@
from dataclasses import dataclass, field
from hooked_containers import events as e
from hooked_containers.mapping import HookedMapping
@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)
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)
class TestHookedMapping:
class TestConstruction:
def test_empty(self):
m = HookedMapping({})
assert m._data == {}
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 dict(m) == existing
def test_nesting(self):
existing = {"a": {"x": 1}, "b": {"y": 2}}
m = HookedMapping(existing)
assert dict(m) == existing
assert dict(m["a"]) == {"x": 1}
assert dict(m["b"]) == {"y": 2}
class TestMappingOps:
def test_setitem(self):
tracker = HookTracker()
m = HookedMapping({}, tracker.hook)
m["a"] = 1
assert m._data == {"a": 1}
assert tracker.added == [1]
def test_nested_setitem(self):
tracker = HookTracker()
m = HookedMapping({"a": {}}, tracker.hook)
m["a"]["x"] = 1
assert m._data == {"a": {"x": 1}}
assert tracker.added == [1]
def test_getitem(self):
m = HookedMapping({"a": 1})
assert m["a"] == 1
def test_delitem(self):
tracker = HookTracker()
m = HookedMapping({"a": 1}, tracker.hook)
del m["a"]
assert not m["a"]
assert tracker.removed == [1]

View File

@@ -12,59 +12,64 @@ def get_id(a: Any):
class TestHookedList: class TestHookedList:
class TestConstruction: class TestConstruction:
def test_empty(self): def test_empty(self):
lst = HookedList(None, existing=[]) lst = HookedList([])
assert list(lst) == [] assert list(lst) == []
def test_with_items(self): def test_with_items(self):
lst = HookedList(None, existing=[1, 2, 3]) lst = HookedList([1, 2, 3])
assert list(lst) == [1, 2, 3] assert list(lst) == [1, 2, 3]
def test_recreation(self): def test_recreation(self):
initial = [1, 2, 3] initial = [1, 2, 3]
initial_id = id(initial) initial_id = id(initial)
lst = HookedList(None, existing=initial) lst = HookedList(initial)
assert id(lst._data) == initial_id assert id(lst._data) == initial_id
lst2 = HookedList(None, existing=lst) lst2 = HookedList(lst)
assert id(lst2._data) == initial_id assert id(lst2._data) == initial_id
class TestSeqOps: class TestSeqOps:
def test_len(self): def test_len(self):
lst = HookedList(None, existing=[1, 2, 3]) lst = HookedList([1, 2, 3])
assert len(lst) == 3 assert len(lst) == 3
def test_getitem(self): def test_getitem(self):
lst = HookedList(None, existing=[1, 2, 3]) lst = HookedList([1, 2, 3])
assert lst[0] == 1 assert lst[0] == 1
assert lst[1] == 2 assert lst[1] == 2
assert lst[-1] == 3 assert lst[-1] == 3
def test_contains(self): def test_contains(self):
lst = HookedList(None, existing=[1, 2, 3]) lst = HookedList([1, 2, 3])
assert 2 in lst assert 2 in lst
assert 4 not in lst assert 4 not in lst
def test_iter(self): def test_iter(self):
lst = HookedList(None, existing=[1, 2, 3]) lst = HookedList([1, 2, 3])
for i, item in enumerate(lst, start=1): for i, item in enumerate(lst, start=1):
assert item == i assert item == i
class TestMutableOps: class TestMutableOps:
def test_setitem(self): def test_setitem(self):
added = [] added = []
lst = HookedList(lambda e: added.append(e.item), existing=[1, 2, 3]) lst = HookedList(
[1, 2, [4, 5, [6, 7]]],
lambda e: added.append(e.item),
)
lst[0] = 10 lst[0] = 10
lst.append(20) lst.append(20)
assert list(lst) == [10, 2, 3, 20]
assert added == [10, 20] assert added == [10, 20]
lst[2][-1].append(8)
assert added == [10, 20, 8]
def test_delitem(self): def test_delitem(self):
lst = HookedList(None, existing=[1, 2, 3]) lst = HookedList([1, 2, 3])
del lst[1] del lst[1]
assert list(lst) == [1, 3] assert list(lst) == [1, 3]
def test_insert(self): def test_insert(self):
lst = HookedList(None, existing=[1, 3]) lst = HookedList([1, 3])
lst.insert(1, 2) lst.insert(1, 2)
assert list(lst) == [1, 2, 3] assert list(lst) == [1, 2, 3]