Compare commits

...

19 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
10 changed files with 201 additions and 80 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,52 +1,59 @@
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 # Custom Methods
def new_path(self, key):
return (*self._path, key)
def get_nested(self, keys): def get_nested(self, keys):
"""Recursively call __getitem__ with each key in the iterable.""" """Recursively call __getitem__ with each key in the iterable."""
result = self result = self

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, Sequence from collections.abc import MutableMapping, MutableSequence
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, MutableNesting from .container import HookedContainer, MutableNesting
from .events import HookFunction
T = TypeVar("T") T = TypeVar("T")
@@ -13,33 +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]
new_path = copy(self._path)
new_path.append(key)
match value: match value:
case MutableMapping() as mapping: case MutableMapping() as mapping:
return HookedMapping(mapping, self.hook, path=new_path) return self.__wrap_sub__(mapping, path=self.new_path(key))
case HookedContainer() as seq:
return type(self)(seq, self.hook, 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,
key: T,
value: MutableNesting[T],
*,
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 self._data[key] = value
if self.hook: case _:
self.hook(e.SetItemEvent(index=key, item=value)) 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:
self.hook(e.RemoveItemEvent(index=key, item=item, path=self._path)) self.hook(e.RemoveItemEvent(self._root, self.new_path(key), 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")
@@ -16,6 +18,7 @@ class HookedList(HookedContainer[T], MutableSequence[T]):
self, self,
existing: MutableSequence[T], existing: MutableSequence[T],
hook: HookFunction | None = None, hook: HookFunction | None = None,
root: MutableNesting[T] | None = None,
path: MutableSequence[int] | None = None, path: MutableSequence[int] | None = None,
) -> None: ) -> None:
self.hook = hook self.hook = hook
@@ -26,6 +29,7 @@ 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):
@@ -33,13 +37,13 @@ class HookedList(HookedContainer[T], MutableSequence[T]):
new_path.append(s) new_path.append(s)
match self._data[s]: match self._data[s]:
case HookedContainer(_data=seq): case HookedContainer(_data=seq):
return type(self)(seq, self.hook, path=new_path) return type(self)(seq, self.hook, root=self._root, path=new_path)
case MutableSequence() as seq: case MutableSequence() as seq:
return HookedList(seq, self.hook, 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]