diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79ca808..d9fd962 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,6 @@ jobs: fail-fast: false matrix: include: - - name: Linux py38 - pyversion: '3.8' - name: Linux py39 pyversion: '3.9' - name: Linux py310 diff --git a/README.md b/README.md index ae91740..0d77da5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Observ 👁 -Observ is a Python port of [Vue.js](https://vuejs.org/)' [computed properties and watchers](https://v3.vuejs.org/api/basic-reactivity.html). It is event loop/framework agnostic and has only one pure-python dependency ([patchdiff](https://github.com/Korijn/patchdiff)) so it can be used in any project targeting Python >= 3.8. +Observ is a Python port of [Vue.js](https://vuejs.org/)' [computed properties and watchers](https://v3.vuejs.org/api/basic-reactivity.html). It is event loop/framework agnostic and has only one pure-python dependency ([patchdiff](https://github.com/Korijn/patchdiff)) so it can be used in any project targeting Python >= 3.9. Observ provides the following two benefits for stateful applications: diff --git a/observ/__init__.py b/observ/__init__.py index bb4b82e..1de1847 100644 --- a/observ/__init__.py +++ b/observ/__init__.py @@ -1,7 +1,7 @@ -__version__ = "0.10.0" +__version__ = "0.11.0" -from .observables import ( +from .proxy import ( reactive, readonly, shallow_reactive, @@ -9,4 +9,4 @@ to_raw, ) from .scheduler import scheduler -from .watcher import computed, watch +from .watcher import computed, watch, watch_effect diff --git a/observ/dict_proxy.py b/observ/dict_proxy.py new file mode 100644 index 0000000..3433860 --- /dev/null +++ b/observ/dict_proxy.py @@ -0,0 +1,78 @@ +from .proxy import Proxy, TYPE_LOOKUP +from .traps import construct_methods_traps_dict, trap_map, trap_map_readonly + + +dict_traps = { + "READERS": { + "copy", + "__eq__", + "__format__", + "__ge__", + "__gt__", + "__le__", + "__len__", + "__lt__", + "__ne__", + "__repr__", + "__sizeof__", + "__str__", + "keys", + "__or__", + "__ror__", + }, + "KEYREADERS": { + "get", + "__contains__", + "__getitem__", + }, + "ITERATORS": { + "items", + "values", + "__iter__", + "__reversed__", + }, + "WRITERS": { + "update", + "__ior__", + }, + "KEYWRITERS": { + "setdefault", + "__setitem__", + }, + "DELETERS": { + "clear", + "popitem", + }, + "KEYDELETERS": { + "pop", + "__delitem__", + }, +} + + +class DictProxyBase(Proxy): + def _orphaned_keydeps(self): + return set(self.proxy_db.attrs(self)["keydep"].keys()) - set(self.target.keys()) + + +def readonly_dict_proxy_init(self, target, shallow=False, **kwargs): + super(ReadonlyDictProxy, self).__init__( + target, shallow=shallow, **{**kwargs, "readonly": True} + ) + + +DictProxy = type( + "DictProxy", + (DictProxyBase,), + construct_methods_traps_dict(dict, dict_traps, trap_map), +) +ReadonlyDictProxy = type( + "ReadonlyDictProxy", + (DictProxyBase,), + { + "__init__": readonly_dict_proxy_init, + **construct_methods_traps_dict(dict, dict_traps, trap_map_readonly), + }, +) + +TYPE_LOOKUP[dict] = (DictProxy, ReadonlyDictProxy) diff --git a/observ/list_proxy.py b/observ/list_proxy.py new file mode 100644 index 0000000..e377ede --- /dev/null +++ b/observ/list_proxy.py @@ -0,0 +1,73 @@ +from .proxy import Proxy, TYPE_LOOKUP +from .traps import construct_methods_traps_dict, trap_map, trap_map_readonly + + +list_traps = { + "READERS": { + "count", + "index", + "copy", + "__add__", + "__getitem__", + "__contains__", + "__eq__", + "__ge__", + "__gt__", + "__le__", + "__lt__", + "__mul__", + "__ne__", + "__rmul__", + "__len__", + "__repr__", + "__str__", + "__format__", + "__sizeof__", + }, + "ITERATORS": { + "__iter__", + "__reversed__", + }, + "WRITERS": { + "append", + "clear", + "extend", + "insert", + "pop", + "remove", + "reverse", + "sort", + "__setitem__", + "__delitem__", + "__iadd__", + "__imul__", + }, +} + + +class ListProxyBase(Proxy): + pass + + +def readonly_list_proxy_init(self, target, shallow=False, **kwargs): + super(ReadonlyListProxy, self).__init__( + target, shallow=shallow, **{**kwargs, "readonly": True} + ) + + +ListProxy = type( + "ListProxy", + (ListProxyBase,), + construct_methods_traps_dict(list, list_traps, trap_map), +) +ReadonlyListProxy = type( + "ReadonlyListProxy", + (ListProxyBase,), + { + "__init__": readonly_list_proxy_init, + **construct_methods_traps_dict(list, list_traps, trap_map_readonly), + }, +) + + +TYPE_LOOKUP[list] = (ListProxy, ReadonlyListProxy) diff --git a/observ/observables.py b/observ/observables.py deleted file mode 100644 index f1dc824..0000000 --- a/observ/observables.py +++ /dev/null @@ -1,621 +0,0 @@ -""" -observe converts plain datastructures (dict, list, set) to -proxied versions of those datastructures to make them reactive. -""" -from functools import partial, wraps -import gc -from operator import xor -import sys -from weakref import WeakValueDictionary - -from .dep import Dep - - -class ProxyDb: - """ - Collection of proxies, tracked by the id of the object that they wrap. - Each time a Proxy is instantiated, it will register itself for the - wrapped object. And when a Proxy is deleted, then it will unregister. - When the last proxy that wraps an object is removed, it is uncertain - what happens to the wrapped object, so in that case the object id is - removed from the collection. - """ - - def __init__(self): - self.db = {} - gc.callbacks.append(self.cleanup) - - def cleanup(self, phase, info): - """ - Callback for garbage collector to cleanup the db for targets - that have no other references outside of the db - """ - # TODO: maybe also run on start? Check performance - if phase != "stop": - return - - keys_to_delete = [] - for key, value in self.db.items(): - # Refs: - # - sys.getrefcount - # - ref in db item - if sys.getrefcount(value["target"]) <= 2: - # We are the last to hold a reference! - keys_to_delete.append(key) - - for keys in keys_to_delete: - del self.db[keys] - - def reference(self, proxy): - """ - Adds a reference to the collection for the wrapped object's id - """ - obj_id = id(proxy.target) - - if obj_id not in self.db: - attrs = { - "dep": Dep(), - } - if isinstance(proxy.target, dict): - attrs["keydep"] = {key: Dep() for key in proxy.target.keys()} - self.db[obj_id] = { - "target": proxy.target, - "attrs": attrs, # dep, keydep - # keyed on tuple(readonly, shallow) - "proxies": WeakValueDictionary(), - } - - # Use setdefault to put the proxy in the proxies dict. If there - # was an existing value, it will return that instead. There shouldn't - # be an existing value, so we can compare the objects to see if we - # should raise an exception. - # Seems to be a tiny bit faster than checking beforehand if - # there is already an existing value in the proxies dict - result = self.db[obj_id]["proxies"].setdefault( - (proxy.readonly, proxy.shallow), proxy - ) - if result is not proxy: - raise RuntimeError("Proxy with existing configuration already in db") - - def dereference(self, proxy): - """ - Removes a reference from the database for the given proxy - """ - obj_id = id(proxy.target) - if obj_id not in self.db: - # When there are failing tests, it might happen that proxies - # are garbage collected at a point where the proxy_db is already - # cleared. That's why we need this check here. - # See fixture [clear_proxy_db](/tests/conftest.py:clear_proxy_db) - # for more info. - return - - # The given proxy is the last proxy in the WeakValueDictionary, - # so now is a good moment to see if can remove clean the deps - # for the target object - if len(self.db[obj_id]["proxies"]) == 1: - ref_count = sys.getrefcount(self.db[obj_id]["target"]) - # Ref count is still 3 here because of the reference through proxy.target - if ref_count <= 3: - # We are the last to hold a reference! - del self.db[obj_id] - - def attrs(self, proxy): - return self.db[id(proxy.target)]["attrs"] - - def get_proxy(self, target, readonly=False, shallow=False): - """ - Returns a proxy from the collection for the given object and configuration. - Will return None if there is no proxy for the object's id. - """ - if id(target) not in self.db: - return None - return self.db[id(target)]["proxies"].get((readonly, shallow)) - - -# Create a global proxy collection -proxy_db = ProxyDb() - - -class Proxy: - """ - Proxy for an object/target. - - Instantiating a Proxy will add a reference to the global proxy_db and - destroying a Proxy will remove that reference. - - Please use the `proxy` method to get a proxy for a certain object instead - of directly creating one yourself. The `proxy` method will either create - or return an existing proxy and makes sure that the db stays consistent. - """ - - __hash__ = None - - def __init__(self, target, readonly=False, shallow=False): - self.target = target - self.readonly = readonly - self.shallow = shallow - proxy_db.reference(self) - - def __del__(self): - proxy_db.dereference(self) - - -def proxy(target, readonly=False, shallow=False): - """ - Returns a Proxy for the given object. If a proxy for the given - configuration already exists, it will return that instead of - creating a new one. - - Please be aware: this only works on plain data types! - """ - # 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: - 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 - - # 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 - existing_proxy = proxy_db.get_proxy(target, readonly=readonly, shallow=shallow) - if existing_proxy is not None: - return existing_proxy - - # We can only wrap the following datatypes - if not isinstance(target, (dict, list, tuple, set)): - return target - - # Otherwise, create a new proxy - proxy_type = None - if isinstance(target, dict): - proxy_type = DictProxy if not readonly else ReadonlyDictProxy - elif isinstance(target, list): - proxy_type = ListProxy if not readonly else ReadonlyListProxy - elif isinstance(target, set): - proxy_type = SetProxy if not readonly else ReadonlySetProxy - elif isinstance(target, tuple): - return tuple(proxy(x, readonly=readonly, shallow=shallow) for x in target) - return proxy_type(target, readonly=readonly, shallow=shallow) - - -reactive = proxy -readonly = partial(proxy, readonly=True) -shallow_reactive = partial(proxy, shallow=True) -shallow_readonly = partial(proxy, shallow=True, readonly=True) - - -class StateModifiedError(Exception): - """ - Raised when a proxy is modified in a watched (or computed) expression. - """ - - pass - - -def read_trap(method, obj_cls): - fn = getattr(obj_cls, method) - - @wraps(fn) - def trap(self, *args, **kwargs): - if Dep.stack: - proxy_db.attrs(self)["dep"].depend() - value = fn(self.target, *args, **kwargs) - if self.shallow: - return value - return proxy(value, readonly=self.readonly) - - return trap - - -def iterate_trap(method, obj_cls): - fn = getattr(obj_cls, method) - - @wraps(fn) - def trap(self, *args, **kwargs): - if Dep.stack: - proxy_db.attrs(self)["dep"].depend() - iterator = fn(self.target, *args, **kwargs) - if self.shallow: - return iterator - if method == "items": - return ( - (key, proxy(value, readonly=self.readonly)) for key, value in iterator - ) - else: - proxied = partial(proxy, readonly=self.readonly) - return map(proxied, iterator) - - return trap - - -def read_key_trap(method, obj_cls): - fn = getattr(obj_cls, method) - - @wraps(fn) - def inner(self, *args, **kwargs): - if Dep.stack: - key = args[0] - keydeps = proxy_db.attrs(self)["keydep"] - if key not in keydeps: - keydeps[key] = Dep() - keydeps[key].depend() - value = fn(self.target, *args, **kwargs) - if self.shallow: - return value - return proxy(value, readonly=self.readonly) - - return inner - - -def write_trap(method, obj_cls): - fn = getattr(obj_cls, method) - - @wraps(fn) - def inner(self, *args, **kwargs): - if Dep.stack: - raise StateModifiedError() - old = self.target.copy() - retval = fn(self.target, *args, **kwargs) - attrs = proxy_db.attrs(self) - if obj_cls == dict: - change_detected = False - keydeps = attrs["keydep"] - for key, val in self.target.items(): - if old.get(key) is not val: - if key in keydeps: - keydeps[key].notify() - else: - keydeps[key] = Dep() - change_detected = True - if change_detected: - attrs["dep"].notify() - else: # list and set - if self.target != old: - attrs["dep"].notify() - - return retval - - return inner - - -def write_key_trap(method, obj_cls): - fn = getattr(obj_cls, method) - getitem_fn = getattr(obj_cls, "get") - - @wraps(fn) - def inner(self, *args, **kwargs): - if Dep.stack: - raise StateModifiedError() - key = args[0] - attrs = proxy_db.attrs(self) - is_new = key not in attrs["keydep"] - old_value = getitem_fn(self.target, key) if not is_new else None - retval = fn(self.target, *args, **kwargs) - if method == "setdefault" and not self.shallow: - # This method is only available when readonly is false - retval = reactive(retval) - - new_value = getitem_fn(self.target, key) - if is_new: - attrs["keydep"][key] = Dep() - if xor(old_value is None, new_value is None) or old_value != new_value: - attrs["keydep"][key].notify() - attrs["dep"].notify() - return retval - - return inner - - -def delete_trap(method, obj_cls): - fn = getattr(obj_cls, method) - - @wraps(fn) - def inner(self, *args, **kwargs): - if Dep.stack: - raise StateModifiedError() - retval = fn(self.target, *args, **kwargs) - attrs = proxy_db.attrs(self) - attrs["dep"].notify() - for key in self._orphaned_keydeps(): - attrs["keydep"][key].notify() - del attrs["keydep"][key] - return retval - - return inner - - -def delete_key_trap(method, obj_cls): - fn = getattr(obj_cls, method) - - @wraps(fn) - def inner(self, *args, **kwargs): - if Dep.stack: - raise StateModifiedError() - retval = fn(self.target, *args, **kwargs) - key = args[0] - attrs = proxy_db.attrs(self) - attrs["dep"].notify() - attrs["keydep"][key].notify() - del attrs["keydep"][key] - return retval - - return inner - - -trap_map = { - "READERS": read_trap, - "KEYREADERS": read_key_trap, - "ITERATORS": iterate_trap, - "WRITERS": write_trap, - "KEYWRITERS": write_key_trap, - "DELETERS": delete_trap, - "KEYDELETERS": delete_key_trap, -} - - -class ReadonlyError(Exception): - """ - Raised when a readonly proxy is modified. - """ - - pass - - -def readonly_trap(method, obj_cls): - fn = getattr(obj_cls, method) - - @wraps(fn) - def inner(self, *args, **kwargs): - raise ReadonlyError() - - return inner - - -trap_map_readonly = { - "READERS": read_trap, - "KEYREADERS": read_key_trap, - "ITERATORS": iterate_trap, - "WRITERS": readonly_trap, - "KEYWRITERS": readonly_trap, - "DELETERS": readonly_trap, - "KEYDELETERS": readonly_trap, -} - - -def bind_traps(proxy_cls, obj_cls, traps, trap_map): - for trap_type, methods in traps.items(): - for method in methods: - trap = trap_map[trap_type](method, obj_cls) - setattr(proxy_cls, method, trap) - - -dict_traps = { - "READERS": { - "copy", - "__eq__", - "__format__", - "__ge__", - "__gt__", - "__le__", - "__len__", - "__lt__", - "__ne__", - "__repr__", - "__sizeof__", - "__str__", - "keys", - }, - "KEYREADERS": { - "get", - "__contains__", - "__getitem__", - }, - "ITERATORS": { - "items", - "values", - "__iter__", - }, - "WRITERS": { - "update", - }, - "KEYWRITERS": { - "setdefault", - "__setitem__", - }, - "DELETERS": { - "clear", - "popitem", - }, - "KEYDELETERS": { - "pop", - "__delitem__", - }, -} - -if sys.version_info >= (3, 8, 0): - dict_traps["ITERATORS"].add("__reversed__") -if sys.version_info >= (3, 9, 0): - dict_traps["READERS"].add("__or__") - dict_traps["READERS"].add("__ror__") - dict_traps["WRITERS"].add("__ior__") - - -class DictProxyBase(Proxy): - def __init__(self, target, readonly=False, shallow=False): - super().__init__(target, readonly=readonly, shallow=shallow) - - def _orphaned_keydeps(self): - return set(proxy_db.attrs(self)["keydep"].keys()) - set(self.target.keys()) - - -class DictProxy(DictProxyBase): - pass - - -class ReadonlyDictProxy(DictProxyBase): - def __init__(self, target, shallow=False, **kwargs): - super().__init__(target, shallow=shallow, **{**kwargs, "readonly": True}) - - -bind_traps(DictProxy, dict, dict_traps, trap_map) -bind_traps(ReadonlyDictProxy, dict, dict_traps, trap_map_readonly) - - -list_traps = { - "READERS": { - "count", - "index", - "copy", - "__add__", - "__getitem__", - "__contains__", - "__eq__", - "__ge__", - "__gt__", - "__le__", - "__lt__", - "__mul__", - "__ne__", - "__rmul__", - "__len__", - "__repr__", - "__str__", - "__format__", - "__sizeof__", - }, - "ITERATORS": { - "__iter__", - "__reversed__", - }, - "WRITERS": { - "append", - "clear", - "extend", - "insert", - "pop", - "remove", - "reverse", - "sort", - "__setitem__", - "__delitem__", - "__iadd__", - "__imul__", - }, -} - - -class ListProxyBase(Proxy): - def __init__(self, target, readonly=False, shallow=False): - super().__init__(target, readonly=readonly, shallow=shallow) - - -class ListProxy(ListProxyBase): - pass - - -class ReadonlyListProxy(ListProxyBase): - def __init__(self, target, shallow=False, **kwargs): - super().__init__(target, shallow=shallow, **{**kwargs, "readonly": True}) - - -bind_traps(ListProxy, list, list_traps, trap_map) -bind_traps(ReadonlyListProxy, list, list_traps, trap_map_readonly) - - -set_traps = { - "READERS": { - "copy", - "difference", - "intersection", - "isdisjoint", - "issubset", - "issuperset", - "symmetric_difference", - "union", - "__and__", - "__contains__", - "__eq__", - "__format__", - "__ge__", - "__gt__", - "__iand__", - "__ior__", - "__isub__", - "__ixor__", - "__le__", - "__len__", - "__lt__", - "__ne__", - "__or__", - "__rand__", - "__repr__", - "__ror__", - "__rsub__", - "__rxor__", - "__sizeof__", - "__str__", - "__sub__", - "__xor__", - }, - "ITERATORS": { - "__iter__", - }, - "WRITERS": { - "add", - "clear", - "difference_update", - "intersection_update", - "discard", - "pop", - "remove", - "symmetric_difference_update", - "update", - }, -} - - -class SetProxyBase(Proxy): - def __init__(self, target, readonly=False, shallow=False): - super().__init__(target, readonly=readonly, shallow=shallow) - - -class SetProxy(SetProxyBase): - pass - - -class ReadonlySetProxy(SetProxyBase): - def __init__(self, target, shallow=False, **kwargs): - super().__init__(target, shallow=shallow, **{**kwargs, "readonly": True}) - - -bind_traps(SetProxy, set, set_traps, trap_map) -bind_traps(ReadonlySetProxy, set, set_traps, trap_map_readonly) - - -def to_raw(target): - """ - Returns a raw object from which any trace of proxy has been replaced - with its wrapped target value. - """ - if isinstance(target, Proxy): - return to_raw(target.target) - - if isinstance(target, list): - return [to_raw(t) for t in target] - - if isinstance(target, dict): - return {key: to_raw(value) for key, value in target.items()} - - if isinstance(target, tuple): - return tuple(to_raw(t) for t in target) - - if isinstance(target, set): - return {to_raw(t) for t in target} - - return target diff --git a/observ/proxy.py b/observ/proxy.py new file mode 100644 index 0000000..0353f7c --- /dev/null +++ b/observ/proxy.py @@ -0,0 +1,108 @@ +from functools import partial + +from .proxy_db import proxy_db + + +class Proxy: + """ + Proxy for an object/target. + + Instantiating a Proxy will add a reference to the global proxy_db and + destroying a Proxy will remove that reference. + + Please use the `proxy` method to get a proxy for a certain object instead + of directly creating one yourself. The `proxy` method will either create + or return an existing proxy and makes sure that the db stays consistent. + """ + + __hash__ = None + __slots__ = ["target", "readonly", "shallow", "proxy_db", "__weakref__"] + + def __init__(self, target, readonly=False, shallow=False): + self.target = target + self.readonly = readonly + self.shallow = shallow + self.proxy_db = proxy_db + self.proxy_db.reference(self) + + def __del__(self): + self.proxy_db.dereference(self) + + +# Lookup dict for mapping a type (dict, list, set) to a method +# that will convert an object of that type to a proxied version +TYPE_LOOKUP = {} + + +def proxy(target, readonly=False, shallow=False): + """ + Returns a Proxy for the given object. If a proxy for the given + configuration already exists, it will return that instead of + creating a new one. + + Please be aware: this only works on plain data types: dict, list, + set and tuple! + """ + # 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: + 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 + + # 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 + existing_proxy = proxy_db.get_proxy(target, readonly=readonly, shallow=shallow) + if existing_proxy is not None: + return existing_proxy + + # We can only wrap the following datatypes + if not isinstance(target, (dict, list, tuple, set)): + return target + + # Otherwise, create a new proxy + proxy_type = None + + for target_type, (writable_proxy_type, readonly_proxy_type) in TYPE_LOOKUP.items(): + if isinstance(target, target_type): + proxy_type = readonly_proxy_type if readonly else writable_proxy_type + break + else: + if isinstance(target, tuple): + return tuple(proxy(x, readonly=readonly, shallow=shallow) for x in target) + + return proxy_type(target, readonly=readonly, shallow=shallow) + + +reactive = proxy +readonly = partial(proxy, readonly=True) +shallow_reactive = partial(proxy, shallow=True) +shallow_readonly = partial(proxy, shallow=True, readonly=True) + + +def to_raw(target): + """ + Returns a raw object from which any trace of proxy has been replaced + with its wrapped target value. + """ + if isinstance(target, Proxy): + return to_raw(target.target) + + if isinstance(target, list): + return [to_raw(t) for t in target] + + if isinstance(target, dict): + return {key: to_raw(value) for key, value in target.items()} + + if isinstance(target, tuple): + return tuple(to_raw(t) for t in target) + + if isinstance(target, set): + return {to_raw(t) for t in target} + + return target diff --git a/observ/proxy_db.py b/observ/proxy_db.py new file mode 100644 index 0000000..d67d71e --- /dev/null +++ b/observ/proxy_db.py @@ -0,0 +1,111 @@ +import gc +import sys +from weakref import WeakValueDictionary + +from .dep import Dep + + +class ProxyDb: + """ + Collection of proxies, tracked by the id of the object that they wrap. + Each time a Proxy is instantiated, it will register itself for the + wrapped object. And when a Proxy is deleted, then it will unregister. + When the last proxy that wraps an object is removed, it is uncertain + what happens to the wrapped object, so in that case the object id is + removed from the collection. + """ + + def __init__(self): + self.db = {} + gc.callbacks.append(self.cleanup) + + def cleanup(self, phase, info): + """ + Callback for garbage collector to cleanup the db for targets + that have no other references outside of the db + """ + # TODO: maybe also run on start? Check performance + if phase != "stop": + return + + keys_to_delete = [] + for key, value in self.db.items(): + # Refs: + # - sys.getrefcount + # - ref in db item + if sys.getrefcount(value["target"]) <= 2: + # We are the last to hold a reference! + keys_to_delete.append(key) + + for keys in keys_to_delete: + del self.db[keys] + + def reference(self, proxy): + """ + Adds a reference to the collection for the wrapped object's id + """ + obj_id = id(proxy.target) + + if obj_id not in self.db: + attrs = { + "dep": Dep(), + } + if isinstance(proxy.target, dict): + attrs["keydep"] = {key: Dep() for key in proxy.target.keys()} + self.db[obj_id] = { + "target": proxy.target, + "attrs": attrs, # dep, keydep + # keyed on tuple(readonly, shallow) + "proxies": WeakValueDictionary(), + } + + # Use setdefault to put the proxy in the proxies dict. If there + # was an existing value, it will return that instead. There shouldn't + # be an existing value, so we can compare the objects to see if we + # should raise an exception. + # Seems to be a tiny bit faster than checking beforehand if + # there is already an existing value in the proxies dict + result = self.db[obj_id]["proxies"].setdefault( + (proxy.readonly, proxy.shallow), proxy + ) + if result is not proxy: + raise RuntimeError("Proxy with existing configuration already in db") + + def dereference(self, proxy): + """ + Removes a reference from the database for the given proxy + """ + obj_id = id(proxy.target) + if obj_id not in self.db: + # When there are failing tests, it might happen that proxies + # are garbage collected at a point where the proxy_db is already + # cleared. That's why we need this check here. + # See fixture [clear_proxy_db](/tests/conftest.py:clear_proxy_db) + # for more info. + return + + # The given proxy is the last proxy in the WeakValueDictionary, + # so now is a good moment to see if can remove clean the deps + # for the target object + if len(self.db[obj_id]["proxies"]) == 1: + ref_count = sys.getrefcount(self.db[obj_id]["target"]) + # Ref count is still 3 here because of the reference through proxy.target + if ref_count <= 3: + # We are the last to hold a reference! + del self.db[obj_id] + + def attrs(self, proxy): + return self.db[id(proxy.target)]["attrs"] + + def get_proxy(self, target, readonly=False, shallow=False): + """ + Returns a proxy from the collection for the given object and configuration. + Will return None if there is no proxy for the object's id. + """ + if id(target) not in self.db: + return None + return self.db[id(target)]["proxies"].get((readonly, shallow)) + + +# Create a global proxy collection +proxy_db = ProxyDb() diff --git a/observ/set_proxy.py b/observ/set_proxy.py new file mode 100644 index 0000000..d99d633 --- /dev/null +++ b/observ/set_proxy.py @@ -0,0 +1,79 @@ +from .proxy import Proxy, TYPE_LOOKUP +from .traps import construct_methods_traps_dict, trap_map, trap_map_readonly + + +set_traps = { + "READERS": { + "copy", + "difference", + "intersection", + "isdisjoint", + "issubset", + "issuperset", + "symmetric_difference", + "union", + "__and__", + "__contains__", + "__eq__", + "__format__", + "__ge__", + "__gt__", + "__iand__", + "__ior__", + "__isub__", + "__ixor__", + "__le__", + "__len__", + "__lt__", + "__ne__", + "__or__", + "__rand__", + "__repr__", + "__ror__", + "__rsub__", + "__rxor__", + "__sizeof__", + "__str__", + "__sub__", + "__xor__", + }, + "ITERATORS": { + "__iter__", + }, + "WRITERS": { + "add", + "clear", + "difference_update", + "intersection_update", + "discard", + "pop", + "remove", + "symmetric_difference_update", + "update", + }, +} + + +class SetProxyBase(Proxy): + pass + + +def readonly_set_proxy_init(self, target, shallow=False, **kwargs): + super(ReadonlySetProxy, self).__init__( + target, shallow=shallow, **{**kwargs, "readonly": True} + ) + + +SetProxy = type( + "SetProxy", (SetProxyBase,), construct_methods_traps_dict(set, set_traps, trap_map) +) +ReadonlySetProxy = type( + "ReadonlysetProxy", + (SetProxyBase,), + { + "__init__": readonly_set_proxy_init, + **construct_methods_traps_dict(set, set_traps, trap_map_readonly), + }, +) + +TYPE_LOOKUP[set] = (SetProxy, ReadonlySetProxy) diff --git a/observ/store.py b/observ/store.py index 5665bba..a983b67 100644 --- a/observ/store.py +++ b/observ/store.py @@ -3,7 +3,7 @@ import patchdiff -from .observables import ( +from .proxy import ( reactive, readonly, shallow_reactive, diff --git a/observ/traps.py b/observ/traps.py new file mode 100644 index 0000000..affb7e0 --- /dev/null +++ b/observ/traps.py @@ -0,0 +1,211 @@ +from functools import partial, wraps +from operator import xor + +from .dep import Dep +from .proxy import proxy + + +class StateModifiedError(Exception): + """ + Raised when a proxy is modified in a watched (or computed) expression. + """ + + pass + + +class ReadonlyError(Exception): + """ + Raised when a readonly proxy is modified. + """ + + pass + + +def read_trap(method, obj_cls): + fn = getattr(obj_cls, method) + + @wraps(fn) + def trap(self, *args, **kwargs): + if Dep.stack: + self.proxy_db.attrs(self)["dep"].depend() + value = fn(self.target, *args, **kwargs) + if self.shallow: + return value + return proxy(value, readonly=self.readonly) + + return trap + + +def iterate_trap(method, obj_cls): + fn = getattr(obj_cls, method) + + @wraps(fn) + def trap(self, *args, **kwargs): + if Dep.stack: + self.proxy_db.attrs(self)["dep"].depend() + iterator = fn(self.target, *args, **kwargs) + if self.shallow: + return iterator + if method == "items": + return ( + (key, proxy(value, readonly=self.readonly)) for key, value in iterator + ) + else: + proxied = partial(proxy, readonly=self.readonly) + return map(proxied, iterator) + + return trap + + +def read_key_trap(method, obj_cls): + fn = getattr(obj_cls, method) + + @wraps(fn) + def trap(self, *args, **kwargs): + if Dep.stack: + key = args[0] + keydeps = self.proxy_db.attrs(self)["keydep"] + if key not in keydeps: + keydeps[key] = Dep() + keydeps[key].depend() + value = fn(self.target, *args, **kwargs) + if self.shallow: + return value + return proxy(value, readonly=self.readonly) + + return trap + + +def write_trap(method, obj_cls): + fn = getattr(obj_cls, method) + + @wraps(fn) + def trap(self, *args, **kwargs): + if Dep.stack: + raise StateModifiedError() + old = self.target.copy() + retval = fn(self.target, *args, **kwargs) + attrs = self.proxy_db.attrs(self) + if obj_cls == dict: + change_detected = False + keydeps = attrs["keydep"] + for key, val in self.target.items(): + if old.get(key) is not val: + if key in keydeps: + keydeps[key].notify() + else: + keydeps[key] = Dep() + change_detected = True + if change_detected: + attrs["dep"].notify() + else: # list and set + if self.target != old: + attrs["dep"].notify() + + return retval + + return trap + + +def write_key_trap(method, obj_cls): + fn = getattr(obj_cls, method) + getitem_fn = getattr(obj_cls, "get") + + @wraps(fn) + def trap(self, *args, **kwargs): + if Dep.stack: + raise StateModifiedError() + key = args[0] + attrs = self.proxy_db.attrs(self) + is_new = key not in attrs["keydep"] + old_value = getitem_fn(self.target, key) if not is_new else None + retval = fn(self.target, *args, **kwargs) + if method == "setdefault" and not self.shallow: + # This method is only available when readonly is false + retval = proxy(retval) + + new_value = getitem_fn(self.target, key) + if is_new: + attrs["keydep"][key] = Dep() + if xor(old_value is None, new_value is None) or old_value != new_value: + attrs["keydep"][key].notify() + attrs["dep"].notify() + return retval + + return trap + + +def delete_trap(method, obj_cls): + fn = getattr(obj_cls, method) + + @wraps(fn) + def trap(self, *args, **kwargs): + if Dep.stack: + raise StateModifiedError() + retval = fn(self.target, *args, **kwargs) + attrs = self.proxy_db.attrs(self) + attrs["dep"].notify() + for key in self._orphaned_keydeps(): + attrs["keydep"][key].notify() + del attrs["keydep"][key] + return retval + + return trap + + +def delete_key_trap(method, obj_cls): + fn = getattr(obj_cls, method) + + @wraps(fn) + def trap(self, *args, **kwargs): + if Dep.stack: + raise StateModifiedError() + retval = fn(self.target, *args, **kwargs) + key = args[0] + attrs = self.proxy_db.attrs(self) + attrs["dep"].notify() + attrs["keydep"][key].notify() + del attrs["keydep"][key] + return retval + + return trap + + +trap_map = { + "READERS": read_trap, + "KEYREADERS": read_key_trap, + "ITERATORS": iterate_trap, + "WRITERS": write_trap, + "KEYWRITERS": write_key_trap, + "DELETERS": delete_trap, + "KEYDELETERS": delete_key_trap, +} + + +def readonly_trap(method, obj_cls): + fn = getattr(obj_cls, method) + + @wraps(fn) + def trap(self, *args, **kwargs): + raise ReadonlyError() + + return trap + + +trap_map_readonly = { + "READERS": read_trap, + "KEYREADERS": read_key_trap, + "ITERATORS": iterate_trap, + "WRITERS": readonly_trap, + "KEYWRITERS": readonly_trap, + "DELETERS": readonly_trap, + "KEYDELETERS": readonly_trap, +} + + +def construct_methods_traps_dict(obj_cls, traps, trap_map): + return { + method: trap_map[trap_type](method, obj_cls) + for trap_type, methods in traps.items() + for method in methods + } diff --git a/observ/watcher.py b/observ/watcher.py index 8e4730d..9f03936 100644 --- a/observ/watcher.py +++ b/observ/watcher.py @@ -6,15 +6,18 @@ from __future__ import annotations from collections.abc import Container -from functools import wraps +from functools import partial, wraps import inspect from itertools import count from typing import Any, Callable, Optional, TypeVar from weakref import ref, WeakSet from .dep import Dep -from .observables import DictProxyBase, ListProxyBase, Proxy, SetProxyBase +from .dict_proxy import DictProxyBase +from .list_proxy import ListProxyBase +from .proxy import Proxy from .scheduler import scheduler +from .set_proxy import SetProxyBase T = TypeVar("T", bound=Callable[[], Any]) @@ -22,7 +25,7 @@ def watch( fn: Callable[[], Any] | Proxy | list[Proxy], - callback: Optional[Callable], + callback: Optional[Callable] = None, sync: bool = False, deep: bool | None = None, immediate: bool = False, @@ -36,6 +39,9 @@ def watch( return watcher +watch_effect = partial(watch, immediate=True, deep=True, callback=None) + + def computed(_fn=None, *, deep=True): def decorator_computed(fn: T) -> T: """ diff --git a/poetry.lock b/poetry.lock index 62f9c1f..f5df503 100644 --- a/poetry.lock +++ b/poetry.lock @@ -545,25 +545,6 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker perf = ["ipython"] testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] -[[package]] -name = "importlib-resources" -version = "6.1.0" -description = "Read resources from Python packages" -category = "dev" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_resources-6.1.0-py3-none-any.whl", hash = "sha256:aa50258bbfa56d4e33fbd8aa3ef48ded10d1735f11532b8df95388cc6bdb7e83"}, - {file = "importlib_resources-6.1.0.tar.gz", hash = "sha256:9d48dcccc213325e810fd723e7fbb45ccb39f6cf5c31f00cf2b965f5f10f3cb9"}, -] - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff", "zipp (>=3.17)"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -625,7 +606,6 @@ files = [ [package.dependencies] importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} -importlib-resources = {version = "*", markers = "python_version < \"3.9\""} "jaraco.classes" = "*" jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} @@ -1220,7 +1200,6 @@ files = [ [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -1376,5 +1355,5 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" -python-versions = ">=3.8" -content-hash = "80e857788dfac5c92b2350fca1fca5e0d0804c7b9e24b4d9f2251df242ceb318" +python-versions = ">=3.9" +content-hash = "5a0c918a8393c9ade652418344675aa466ccbb560792764aab80564d58ba0274" diff --git a/pyproject.toml b/pyproject.toml index 4a0246f..6d8ec99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "observ" -version = "0.10.0" +version = "0.11.0" description = "Reactive state management for Python" authors = ["Korijn van Golen ", "Berend Klein Haneveld "] license = "MIT" @@ -8,7 +8,7 @@ homepage = "https://github.com/fork-tongue/observ" readme = "README.md" [tool.poetry.dependencies] -python = ">=3.8" +python = ">=3.9" patchdiff = "~0.3.4" [tool.poetry.group.dev.dependencies] diff --git a/tests/conftest.py b/tests/conftest.py index d2d1d59..03a8e52 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ import pytest from observ import scheduler -from observ.observables import proxy_db +from observ.proxy import proxy_db def noop(): diff --git a/tests/test_collections.py b/tests/test_collections.py index 3c47f38..d554bff 100644 --- a/tests/test_collections.py +++ b/tests/test_collections.py @@ -1,9 +1,10 @@ from unittest.mock import Mock from observ.dep import Dep -from observ.observables import dict_traps, list_traps, set_traps -from observ.observables import DictProxy, ListProxy, SetProxy -from observ.observables import proxy_db +from observ.dict_proxy import dict_traps, DictProxy +from observ.list_proxy import list_traps, ListProxy +from observ.proxy_db import proxy_db +from observ.set_proxy import set_traps, SetProxy COLLECTIONS = { @@ -47,6 +48,13 @@ # __del__ is custom method on Proxy "__del__", "__getstate__", + # Following attributes are part of Proxy.__slots__ + "__slots__", + "target", + "readonly", + "shallow", + "proxy_db", + "__weakref__", } diff --git a/tests/test_deps.py b/tests/test_deps.py index 994296a..9fb488b 100644 --- a/tests/test_deps.py +++ b/tests/test_deps.py @@ -1,5 +1,5 @@ from observ import computed, reactive -from observ.observables import proxy_db +from observ.proxy import proxy_db def test_deps_copy(): diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 3694414..49fe792 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -2,18 +2,12 @@ import pytest -from observ.observables import ( - DictProxy, - ListProxy, - Proxy, - proxy, - proxy_db, - ReadonlyDictProxy, - ReadonlyError, - ReadonlyListProxy, - ReadonlySetProxy, - SetProxy, -) +from observ.dict_proxy import DictProxy, ReadonlyDictProxy +from observ.list_proxy import ListProxy, ReadonlyListProxy +from observ.proxy import Proxy, proxy +from observ.proxy_db import proxy_db +from observ.set_proxy import ReadonlySetProxy, SetProxy +from observ.traps import ReadonlyError def test_proxy_lifecycle(): diff --git a/tests/test_usage.py b/tests/test_usage.py index b412ef4..2a31336 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -4,7 +4,9 @@ import pytest from observ import computed, reactive, to_raw, watch -from observ.observables import ListProxy, Proxy, StateModifiedError +from observ.list_proxy import ListProxy +from observ.proxy import Proxy +from observ.traps import StateModifiedError from observ.watcher import WrongNumberOfArgumentsError @@ -468,7 +470,6 @@ def _expr_with_write(): def test_watch_computed(): a = reactive([0]) - from observ.observables import ListProxy assert isinstance(a, ListProxy) diff --git a/tests/test_watch_effect.py b/tests/test_watch_effect.py new file mode 100644 index 0000000..c265b0d --- /dev/null +++ b/tests/test_watch_effect.py @@ -0,0 +1,43 @@ +from observ import reactive, scheduler +from observ.watcher import watch_effect + + +def test_watch_effect(): + state = reactive({"count": 0}) + count_mirror = -1 + + def bump(): + nonlocal count_mirror + count_mirror = state["count"] + + watcher = watch_effect(bump, sync=True) + assert watcher.lazy is False + + assert state["count"] == 0 + assert count_mirror == 0 + + state["count"] = 1 + + assert state["count"] == 1 + assert count_mirror == 1 + + +def test_watch_effect_scheduled(noop_request_flush): + state = reactive({"count": 0}) + count_mirror = -1 + + def bump(): + nonlocal count_mirror + count_mirror = state["count"] + + watcher = watch_effect(bump) + assert watcher.lazy is False + + assert state["count"] == 0 + assert count_mirror == 0 + + state["count"] = 1 + scheduler.flush() + + assert state["count"] == 1 + assert count_mirror == 1