Ignacio Mariotti
Ignacio Mariotti

Reputation: 41

Decorating python properties with an observable pattern

I'm trying to create an observable decorator that adds the method "add_observer" to a property. The main issue I'm having is that I do not know how to treat the getter. It would be amazing if I could extend the underlying property with the method somehow.

class observable:
    def __init__(self, fget):
        self.observers = []
        self.fget = fget
        self.fset = None
        self.name = fget.__name__

    def add_observer(self, observer):
        self.observers.append(observer)

    def setter(self, fset):
        self.fset = fset
        return self

    def __set__(self, obj, value) -> None:

        if self.fset is None:
            raise AttributeError(f"Setter not defined for property {self.name}")
        self.fset(obj, value)

        for observer in self.observers:
            observer(value)

    def __get__(self, obj, objtype=None):
        return self


class Options:

    def __init__(self):
        self._debug: bool = False

    @observable
    def debug(self) -> bool:
        return self._debug

    @debug.setter
    def debug(self, debug: bool):
        self._debug = debug


opt = Options()
print(opt.debug)
opt.debug.add_observer(lambda value: print(f"Debug is {value}"))
opt.debug = True
opt.debug = False
<__main__.observable object at 0x7f446f854500>
Debug is True
Debug is False

I cannot use opt.debug as a boolean value. I tried alternatives such as modifying the owner with owner.add_observable(owner.debug, lambda x: 'hello!') which is pretty close to what I want but not close enough. Mostly because I wanted autocomplete to work.

Also, I'm getting a shadowing error on the IDE that @property does not trigger, how come?: shadowing

Upvotes: 0

Views: 83

Answers (1)

couteau
couteau

Reputation: 319

It's not completely clear how you are expecting this to work, but there are several issues with this approach.

  1. Your __get__ method never returns the value of the property because it never calls self.fget. A descriptor's __get__ method takes a reference to an instance and a reference to the class, and if the instance is not None is supposed to return the property value for the instance, and if the class is not None, is supposed to return a reference to the property object.

    By way of example, the python equivalent of the native property class is something like:

def __get__(self, obj, objtype=None):
    if obj is None:
        return self
    if self.fget is None:
        raise AttributeError("Attribute has no getter")
    return self.fget(obj)
  1. I'm not sure if it is your intention or not, but the way you have implemented this, when an observer is added, it is added for the class, not for the instance.

    That is, in your implementation, when you call opt.debug.set_observer, that observer will be called when the debug property is set on any instance of the Options class, not just the instance referenced by the variable opt, because properties exist at the class level. That is, there is only one instance of observable for the Options class's debug property.

    If that is indeed the desired behavior for your use case, and you implment __get__ as described above, then the way you would access your observable instance instead of the value of the debug property is to call type(opt).debug.add_observer instead of opt.debug.add_observer (or alternatively Options.debug.add_observer).

    If you want to be able to add per instance observers, then you need to come up with another implementation.

  2. Just as an extra tip, there is a better way to set the name of the property than reading it from the getter method. When you define a descriptor, if your descriptor class has a method called __set_name__, that will be called with a reference to the class the name of the property as part of class construction.

Putting it all together, your example could be rewritten as follows.

class observable:
    def __init__(self, fget, fset = None):
        self.observers = []
        self.fget = fget
        self.fset = fset

    def __set_name__(self, owner, name):
        self.name = name

    def add_observer(self, observer):
        self.observers.append(observer)

    def setter(self, fset):
        self.fset = fset
        return self

    def __set__(self, obj, value) -> None:
        if self.fset is None:
            raise AttributeError(f"Setter not defined for property {self.name}")
        self.fset(obj, value)

        for observer in self.observers:
            observer(value)
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError(f"Attribute {name} has no getter")
        return self.fget(obj)


class Options:
    def __init__(self):
        self._debug: bool = False

    @observable
    def debug(self) -> bool:
        return self._debug

    @debug.setter
    def debug(self, debug: bool):
        self._debug = debug


opt = Options()
print(opt.debug)
type(opt).debug.add_observer(lambda value: print(f"Debug is {value}"))
opt.debug = True
opt.debug = False

opt2 = Options()
opt2.debug = True # the observer will be called here, too, even though no observer was set on opt2

Upvotes: 0

Related Questions