Reputation: 41
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?:
Upvotes: 0
Views: 83
Reputation: 319
It's not completely clear how you are expecting this to work, but there are several issues with this approach.
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)
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.
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