diff --git a/observ/dict_proxy.py b/observ/dict_proxy.py index 8b8b774..97772b7 100644 --- a/observ/dict_proxy.py +++ b/observ/dict_proxy.py @@ -75,4 +75,9 @@ def readonly_dict_proxy_init(self, target, shallow=False, **kwargs): }, ) -TYPE_LOOKUP[dict] = (DictProxy, ReadonlyDictProxy) + +def type_test(target): + return isinstance(target, dict) + + +TYPE_LOOKUP[type_test] = (DictProxy, ReadonlyDictProxy) diff --git a/observ/list_proxy.py b/observ/list_proxy.py index a7c2386..005095f 100644 --- a/observ/list_proxy.py +++ b/observ/list_proxy.py @@ -69,4 +69,8 @@ def readonly_list_proxy_init(self, target, shallow=False, **kwargs): ) -TYPE_LOOKUP[list] = (ListProxy, ReadonlyListProxy) +def type_test(target): + return isinstance(target, list) + + +TYPE_LOOKUP[type_test] = (ListProxy, ReadonlyListProxy) diff --git a/observ/object_proxy.py b/observ/object_proxy.py new file mode 100644 index 0000000..bbb31b8 --- /dev/null +++ b/observ/object_proxy.py @@ -0,0 +1,98 @@ +from .proxy import TYPE_LOOKUP, Proxy +from .proxy_db import proxy_db +from .traps import construct_methods_traps_dict, trap_map, trap_map_readonly + +###################### +# UNTRAPPED ATTRIBUTES +###################### + +# CREATION HOOKS: +# '__init__', +# '__init_subclass__', +# '__new__', + +# PICKLE HOOKS: +# '__reduce__', +# '__reduce_ex__', +# '__getstate__', + +# WEAKREF STORAGE: +# '__weakref__', + +# FORMATTING HOOKS: +# '__format__', + +# INTERNALS: +# '__dict__', + +########### +# QUESTIONS +########### +# I don't think we can maintain per-key reactivity +# like we do with dicts. For starters we can't +# query the initial state since there is no .keys() + +# There are many _optional_ magic methods, such as __iter__ +# How do we support those without overcomplicating the traps? + + + +object_traps = { + "READERS": { + '__class__', + '__dir__', + '__doc__', + '__eq__', + '__ge__', + '__gt__', + '__hash__', + '__le__', + '__lt__', + '__module__', + '__ne__', + '__repr__', + '__sizeof__', + '__str__', + '__subclasshook__', + # also fires for method access :/ + '__getattribute__', + }, + "WRITERS": { + '__setattr__', + "__delattr__", + }, +} + + +class ObjectProxyBase(Proxy[object]): + def _orphaned_keydeps(self): + return set(proxy_db.attrs(self)["keydep"].keys()) - set(vars(self.target).keys()) + + +def readonly_object_proxy_init(self, target, shallow=False, **kwargs): + super(ReadonlyObjectProxy, self).__init__( + target, shallow=shallow, **{**kwargs, "readonly": True} + ) + + +ObjectProxy = type( + "ObjectProxy", + (ObjectProxyBase,), + construct_methods_traps_dict(object, object_traps, trap_map), +) +ReadonlyObjectProxy = type( + "ReadonlyObjectProxy", + (ObjectProxyBase,), + { + "__init__": readonly_object_proxy_init, + **construct_methods_traps_dict(object, object_traps, trap_map_readonly), + }, +) + + +def type_test(target): + # exclude builtin objects + return isinstance(target, object) and target.__module__ != object.__module__ + + +TYPE_LOOKUP[type_test] = (ObjectProxy, ReadonlyObjectProxy) diff --git a/observ/proxy.py b/observ/proxy.py index 2119ae3..c67d15d 100644 --- a/observ/proxy.py +++ b/observ/proxy.py @@ -66,8 +66,8 @@ def proxy(target: T, readonly=False, shallow=False) -> T: return existing_proxy # Create a new proxy - for target_type, (writable_proxy_type, readonly_proxy_type) in TYPE_LOOKUP.items(): - if isinstance(target, target_type): + for type_test, (writable_proxy_type, readonly_proxy_type) in TYPE_LOOKUP.items(): + if type_test(target): proxy_type = readonly_proxy_type if readonly else writable_proxy_type return proxy_type(target, readonly=readonly, shallow=shallow) diff --git a/observ/set_proxy.py b/observ/set_proxy.py index 5a46ed8..1ec6c71 100644 --- a/observ/set_proxy.py +++ b/observ/set_proxy.py @@ -75,4 +75,9 @@ def readonly_set_proxy_init(self, target, shallow=False, **kwargs): }, ) -TYPE_LOOKUP[set] = (SetProxy, ReadonlySetProxy) + +def type_test(target): + return isinstance(target, set) + + +TYPE_LOOKUP[type_test] = (SetProxy, ReadonlySetProxy) diff --git a/observ/watcher.py b/observ/watcher.py index 01c4887..770ed40 100644 --- a/observ/watcher.py +++ b/observ/watcher.py @@ -16,6 +16,7 @@ from .dep import Dep from .dict_proxy import DictProxyBase from .list_proxy import ListProxyBase +from .object_proxy import ObjectProxyBase from .scheduler import scheduler from .set_proxy import SetProxyBase @@ -83,8 +84,14 @@ def traverse(obj, seen=None): val_iter = iter(obj.values()) elif isinstance(obj, (list, ListProxyBase, set, SetProxyBase, tuple)): val_iter = iter(obj) + elif isinstance(obj, (object, ObjectProxyBase)): + try: + val_iter = iter(vars(obj).values()) + except TypeError: + return else: return + # track which objects we have already seen to support(!) full traversal # of datastructures with cycles # NOTE: a set would provide faster containment checks diff --git a/tests/test_object_proxy.py b/tests/test_object_proxy.py new file mode 100644 index 0000000..69fe9ad --- /dev/null +++ b/tests/test_object_proxy.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from observ.object_proxy import ObjectProxy +from observ.proxy import proxy +from observ.traps import ReadonlyError + + +@dataclass +class Foo: + bar: int + + +def test_dataclass_proxy(): + data = Foo(bar=5) + proxied = proxy(data) + assert isinstance(proxied, ObjectProxy)