Compare commits

..

34 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
John Lancaster
842868c491 path in change events 2026-02-21 23:07:30 -06:00
John Lancaster
54e2febf3b started pytests 2026-02-21 22:51:50 -06:00
John Lancaster
58ab0aae6c reorg 2026-02-21 22:51:21 -06:00
John Lancaster
1d2fcd13f9 abstractmethods 2026-02-21 22:30:50 -06:00
John Lancaster
428db7366e variable typing 2026-02-21 19:47:32 -06:00
John Lancaster
32ee15348b mutablenesting 2026-02-21 19:14:30 -06:00
John Lancaster
8d598ff135 mapping nested types 2026-02-21 19:11:55 -06:00
John Lancaster
89f533d9bf pruning 2026-02-21 19:01:03 -06:00
John Lancaster
2405d670fb broke apart 2026-02-21 19:00:10 -06:00
13 changed files with 432 additions and 86 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

@@ -19,6 +19,9 @@ dev = [
"rich>=14.3.3", "rich>=14.3.3",
"ruff>=0.15.2", "ruff>=0.15.2",
] ]
test = [
"pytest>=9.0.2",
]
[tool.ruff] [tool.ruff]
line-length = 120 line-length = 120

View File

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

View File

@@ -0,0 +1,62 @@
from abc import ABC, abstractmethod
from collections.abc import Container, Iterable, MutableMapping, MutableSequence, Sized
from typing import TypeVar
from . import events as e
from .events import HookFunction
from .types import MutableNesting
T = TypeVar("T")
class HookedContainer(ABC, Sized, Iterable[T], Container[T]):
_data: MutableNesting[T]
_path: MutableSequence[int]
_root: MutableMapping[T] | MutableSequence[T] | None = None
hook: HookFunction
def __repr__(self):
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
# __contains__, __iter__, __reversed__, index, and count
@abstractmethod
def __getitem__(self, key): ...
# MutableSequence Methods
# append, reverse, extend, pop, remove, and __iadd__
def __setitem__(self, s, value):
self._data[s] = value
if self.hook:
self.hook(e.SetItemEvent(self._root, self.new_path(s), value))
def __delitem__(self, s):
item = self._data.pop(s)
if self.hook:
self.hook(e.RemoveItemEvent(self._root, self.new_path(s), item))
# @abstractmethod
# 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

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

View File

@@ -0,0 +1,72 @@
from collections.abc import MutableMapping, MutableSequence
from typing import TypeVar
from . import events as e
from .container import HookedContainer, MutableNesting
from .events import HookFunction
T = TypeVar("T")
class HookedMapping(HookedContainer[T], MutableMapping[T, MutableNesting[T]]):
_data: MutableMapping[T, MutableNesting[T]]
def __init__(
self,
existing: MutableMapping[T, MutableNesting[T]],
hook: HookFunction | None = None,
root: MutableMapping[T] | None = None,
path: MutableSequence[int] | None = None,
):
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.hook = hook
self._root = root if root is not None else self
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]:
if key not in self._data:
return self.__wrap_sub__(self.__setitem__(key, {}), path=self.new_path(key))
value = self._data[key]
match value:
case MutableMapping() as mapping:
return self.__wrap_sub__(mapping, path=self.new_path(key))
case _ as item:
return item
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
case _:
self._data[key] = value
if self.hook and not suppress_hook:
self.hook(event)
return value
def __delitem__(self, key: T) -> None:
item = self._data.pop(key)
if self.hook:
self.hook(e.RemoveItemEvent(self._root, self.new_path(key), item))

View File

@@ -1,85 +1,49 @@
from collections.abc import Callable, Iterable, MutableSequence, Sequence from collections.abc import MutableSequence
from dataclasses import dataclass from copy import copy
from enum import Enum, auto from typing import TypeVar
from typing import Generic, TypeVar
from . import events as e
from .container import HookedContainer
from .events import HookFunction
from .types import MutableNesting
T = TypeVar("T") T = TypeVar("T")
class ListChange(Enum): class HookedList(HookedContainer[T], MutableSequence[T]):
ADD_ITEM = auto()
REMOVE_ITEM = auto()
SET_ITEM = auto()
@dataclass(frozen=True)
class ChangeEvent(Generic[T]):
index: int
@dataclass(frozen=True)
class AddItemEvent(ChangeEvent[T]):
item: T
@dataclass(frozen=True)
class SetItemEvent(ChangeEvent[T]):
item: T
@dataclass(frozen=True)
class RemoveItemEvent(ChangeEvent[T]):
item: T
class HookedList(Generic[T], MutableSequence[T]):
_data: MutableSequence[T] _data: MutableSequence[T]
_path: MutableSequence[int] path: MutableSequence[int]
hook: Callable[[ChangeEvent[T]], None] | None
def __init__(self, iterable: Iterable[T], path: Sequence[int] | None = None, *, hook=None): def __init__(
match iterable: self,
existing: MutableSequence[T],
hook: HookFunction | None = None,
root: MutableNesting[T] | None = None,
path: MutableSequence[int] | None = None,
) -> None:
self.hook = hook
match existing:
case HookedContainer(_data=seq):
self._data = seq
case MutableSequence() as seq: case MutableSequence() as seq:
self._data = seq self._data = seq
case Iterable() 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 []
self.hook = hook
def __repr__(self):
return f"{self.__class__.__name__}({self._data!r})"
def __iter__(self):
return iter(self._data)
def __contains__(self, value):
return value in self._data
def __len__(self):
return len(self._data)
def __getitem__(self, s): def __getitem__(self, s):
# print("Getting item:", s) new_path = copy(self._path)
new_path.append(s)
match self._data[s]: match self._data[s]:
case HookedList() as hs: case HookedContainer(_data=seq):
hs.hook = self.hook return type(self)(seq, self.hook, root=self._root, path=new_path)
return hs
case MutableSequence() as seq: case MutableSequence() as seq:
return HookedList(seq, hook=self.hook) return HookedList(seq, self.hook, root=self._root, path=new_path)
case _ as item: case _ as item:
return item return item
def __setitem__(self, s, value):
self._data[s] = value
if self.hook:
self.hook(SetItemEvent(index=s, item=value))
def __delitem__(self, s):
del self._data[s]
if self.hook:
self.hook(RemoveItemEvent(index=s, item=self._data[s]))
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(AddItemEvent(index=index, item=value)) self.hook(e.AddItemEvent(self._root, self.new_path(index), value))

View File

@@ -0,0 +1,43 @@
from collections.abc import MutableMapping
from datetime import datetime
from .mapping import HookedMapping
class EntityState(HookedMapping[str]):
def __setitem__(self, key, value):
super().__setitem__(key, value)
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]):
_data: MutableMapping[str, DomainState]
def __getitem__(self, key):
match super().__getitem__(key):
case HookedMapping(_data=val):
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]

75
tests/test_sequence.py Normal file
View File

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

38
uv.lock generated
View File

@@ -145,6 +145,9 @@ dev = [
{ name = "rich" }, { name = "rich" },
{ name = "ruff" }, { name = "ruff" },
] ]
test = [
{ name = "pytest" },
]
[package.metadata] [package.metadata]
@@ -154,6 +157,16 @@ dev = [
{ name = "rich", specifier = ">=14.3.3" }, { name = "rich", specifier = ">=14.3.3" },
{ name = "ruff", specifier = ">=0.15.2" }, { name = "ruff", specifier = ">=0.15.2" },
] ]
test = [{ name = "pytest", specifier = ">=9.0.2" }]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "ipykernel" name = "ipykernel"
@@ -334,6 +347,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "prompt-toolkit" name = "prompt-toolkit"
version = "3.0.52" version = "3.0.52"
@@ -410,6 +432,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
] ]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"