Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Object proxy #121

Merged
merged 16 commits into from
Jan 9, 2024
2 changes: 1 addition & 1 deletion observ/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "0.14.1"
__version__ = "0.15.0"


from .init import init, loop_factory
Expand Down
9 changes: 7 additions & 2 deletions observ/dict_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@

class DictProxyBase(Proxy[dict]):
def _orphaned_keydeps(self):
return set(proxy_db.attrs(self)["keydep"].keys()) - set(self.target.keys())
return set(proxy_db.attrs(self)["keydep"].keys()) - set(self.__target__.keys())


def readonly_dict_proxy_init(self, target, shallow=False, **kwargs):
Expand All @@ -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)
6 changes: 5 additions & 1 deletion observ/list_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
255 changes: 255 additions & 0 deletions observ/object_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
from operator import xor

from .dep import Dep
from .object_utils import get_object_attrs
from .proxy import TYPE_LOOKUP, Proxy, proxy
from .proxy_db import proxy_db
from .traps import ReadonlyError


class ObjectProxyBase(Proxy):
def __getattribute__(self, name):
if name in Proxy.__slots__:
return super().__getattribute__(name)

target = self.__target__
target_attrs = get_object_attrs(target)
if name not in target_attrs:
return getattr(target, name)

if Dep.stack:
keydeps = proxy_db.attrs(self)["keydep"]
if name not in keydeps:
keydeps[name] = Dep()
keydeps[name].depend()
value = getattr(target, name)
if self.__shallow__:
return value
return proxy(value, readonly=self.__readonly__)

def __setattr__(self, name, value):
if name in Proxy.__slots__:
return super().__setattr__(name, value)

if self.__readonly__:
raise ReadonlyError()

target = self.__target__
target_attrs = get_object_attrs(target)
attrs = proxy_db.attrs(self)
is_new_keydep = name not in attrs["keydep"]
is_new_target_attr = name not in target_attrs

old_value = getattr(target, name, None) if not is_new_target_attr else None
retval = setattr(target, name, value)

if is_new_keydep:
# we have not determined earlier that this attr
# should be reactive, so let's determine it now
target_attrs_new = get_object_attrs(target)
if name not in target_attrs_new:
# the set attr is not stateful (e.g. someone
# is attaching a bound method)
# so no need to track this modification
return retval

new_value = getattr(target, name, None)
Korijn marked this conversation as resolved.
Show resolved Hide resolved
if is_new_keydep:
attrs["keydep"][name] = Dep()
if xor(old_value is None, new_value is None) or old_value != new_value:
attrs["keydep"][name].notify()
return retval

def __delattr__(self, name):
if name in Proxy.__slots__:
return super().__delattr__(name)

if self.__readonly__:
raise ReadonlyError()

target = self.__target__
target_attrs = get_object_attrs(target)
attrs = proxy_db.attrs(self)
is_keydep = name in attrs["keydep"]
is_target_attr = name in target_attrs

retval = delattr(target, name)

if is_target_attr and is_keydep:
attrs["keydep"][name].notify()
del attrs["keydep"][name]

return retval


def passthrough(method):
def trap(self, *args, **kwargs):
fn = getattr(self.__target__, method, None)
if fn is None:
Korijn marked this conversation as resolved.
Show resolved Hide resolved
# we don't cache this
# since it is possible a class is dynamically modified later
# invalidating the cached result...
raise TypeError(f"object of type '{type(self)}' has no {method}")
return fn(*args, **kwargs)

return trap


# TODO: how to verify this is the correct and complete set of magic methods?
# scraped from https://docs.python.org/3/reference/datamodel.html
# as of py3.12
magic_methods = [
"__abs__",
"__add__",
"__aenter__",
"__aexit__",
"__aiter__",
"__and__",
"__anext__",
"__annotations__",
"__await__",
"__bases__",
"__bool__",
"__buffer__",
"__bytes__",
"__call__",
"__ceil__",
"__class__",
"__class_getitem__",
# '__classcell__',
"__closure__",
"__code__",
"__complex__",
"__contains__",
"__defaults__",
# '__del__',
# '__delattr__',
"__delete__",
"__delitem__",
"__dict__",
"__dir__",
"__divmod__",
"__doc__",
"__enter__",
"__eq__",
"__exit__",
"__file__",
"__float__",
"__floor__",
"__floordiv__",
"__format__",
"__func__",
"__future__",
"__ge__",
"__get__",
# "__getattr__",
# '__getattribute__',
"__getitem__",
"__globals__",
"__gt__",
"__hash__",
"__iadd__",
"__iand__",
"__ifloordiv__",
"__ilshift__",
"__imatmul__",
"__imod__",
"__import__",
"__imul__",
"__index__",
# '__init__',
"__init_subclass__",
"__instancecheck__",
"__int__",
"__invert__",
"__ior__",
"__ipow__",
"__irshift__",
"__isub__",
"__iter__",
"__itruediv__",
"__ixor__",
"__kwdefaults__",
"__le__",
"__len__",
"__length_hint__",
"__lshift__",
"__lt__",
"__match_args__",
"__matmul__",
"__missing__",
"__mod__",
"__module__",
"__mro__",
"__mro_entries__",
"__mul__",
"__name__",
"__ne__",
"__neg__",
# '__new__',
"__next__",
"__objclass__",
"__or__",
"__pos__",
"__pow__",
"__prepare__",
# '__qualname__',
"__radd__",
"__rand__",
"__rdivmod__",
"__release_buffer__",
"__repr__",
"__reversed__",
"__rfloordiv__",
"__rlshift__",
"__rmatmul__",
"__rmod__",
"__rmul__",
"__ror__",
"__round__",
"__rpow__",
"__rrshift__",
"__rshift__",
"__rsub__",
"__rtruediv__",
"__rxor__",
"__self__",
"__set__",
"__set_name__",
# '__setattr__',
"__setitem__",
# '__slots__',
"__str__",
"__sub__",
"__subclasscheck__",
"__traceback__",
"__truediv__",
"__trunc__",
"__type_params__",
"__weakref__",
"__xor__",
]


ObjectProxy = type(
"ObjectProxy",
(ObjectProxyBase,),
{method: passthrough(method) for method in magic_methods},
)


class ReadonlyObjectProxy(ObjectProxy):
def __init__(self, target, shallow=False, **kwargs):
super().__init__(target, shallow=shallow, **{**kwargs, "readonly": True})


def type_test(target):
# exclude builtin objects
# exclude objects for which we have better proxies available
# exclude ndarrays
return not isinstance(target, (list, set, dict, tuple)) and type(
target
).__module__ not in (object.__module__, "numpy")


TYPE_LOOKUP[type_test] = (ObjectProxy, ReadonlyObjectProxy)
27 changes: 27 additions & 0 deletions observ/object_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from functools import cache
from itertools import chain


@cache
def get_class_slots(cls):
"""utility to collect all __slots__ entries for a given type and its supertypes"""
# collect via iterables for performance
# deduplicate via set
return set(
chain.from_iterable(getattr(cls, "__slots__", []) for cls in cls.__mro__)
berendkleinhaneveld marked this conversation as resolved.
Show resolved Hide resolved
)


def get_object_attrs(obj):
"""utility to collect all stateful attributes of an object"""
# __slots__ from full class ancestry
attrs = get_class_slots(type(obj))
try:
# all __dict__ entries
obj_keys = vars(obj).keys()
if obj_keys:
attrs = attrs.copy()
attrs.update(obj_keys)
except TypeError:
pass
return attrs
20 changes: 11 additions & 9 deletions observ/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ class Proxy(Generic[T]):
"""

__hash__ = None
__slots__ = ("target", "readonly", "shallow", "__weakref__")
# the slots have to be very unique since we also proxy objects
# which may define the attributes with the same names
__slots__ = ("__target__", "__readonly__", "__shallow__", "__weakref__")
berendkleinhaneveld marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, target: T, readonly=False, shallow=False):
self.target = target
self.readonly = readonly
self.shallow = shallow
self.__target__ = target
self.__readonly__ = readonly
self.__shallow__ = shallow
proxy_db.reference(self)

def __del__(self):
Expand All @@ -50,14 +52,14 @@ def proxy(target: T, readonly=False, shallow=False) -> T:
# The object may be a proxy already, so check if it matches the
# given configuration (readonly and shallow)
if isinstance(target, Proxy):
if readonly == target.readonly and shallow == target.shallow:
if readonly == target.__readonly__ and shallow == target.__shallow__:
return target
else:
# If the configuration does not match,
# unwrap the target from the proxy so that the right
# kind of proxy can be returned in the next part of
# this function
target = target.target
target = target.__target__

# Note that at this point, target is always a non-proxy object
# Check the proxy_db to see if there's already a proxy for the target object
Expand All @@ -66,8 +68,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)

Expand Down Expand Up @@ -106,7 +108,7 @@ def to_raw(target: Proxy[T] | T) -> T:
with its wrapped target value.
"""
if isinstance(target, Proxy):
return to_raw(target.target)
return to_raw(target.__target__)

if isinstance(target, list):
return cast(T, [to_raw(t) for t in target])
Expand Down
Loading
Loading