how to add methods to descriptors or properties in python

602 Views Asked by At

I am trying to write a simulation class that can easily be extended. For this I'd like to use something similar to a property, but that also provides an update method that could be implemented differently for different use cases:

class Quantity(object):
    
    def __init__(self, initval=None):
        self.value = initval

    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        self.value = value
    
    def update(self, parent):
        """here the quantity should be updated using also values from
        MySimulation, e.g. adding `MySimulation.increment`, but I don't
        know how to link to the parent simulation."""

        
class MySimulation(object):
    "this default simulation has only density"
    density = Quantity()
    increment = 1
    
    def __init__(self, value):
        self.density = value
    
    def update(self):
        """this one does not work because self.density returns value
        which is a numpy array in the example and thus we cannot access
        the update method"""
        self.density.update(self)

The default simulation could the be used like this:

sim = MySimulation(np.arange(5))

# we can get the values like this
print(sim.density)
> [0, 1, 2, 3, 4]

# we can call update and all quantities should update
sim.update()  # <- this one is not possible

I would like to write it in such a way such that the simulation can be extended in any user-defined way, for example adding another quantity that is updated differently:

class Temperature(Quantity):
    def update(self, parent):
        "here we define how to update a temperature"


class MySimulation2(MySimulation):
    "an improved simulation that also evolves temperature"
    temperature = Temperature()
    
    def __init__(self, density_value, temperature_value):
        super().__init__(density_value)
        self.temperature = temperature_value
    
    def update(self):
        self.density.update(self)
        self.temperature.update(self)

Is that possible somehow or is there another way to achieve a similar behavior? I have seen this question, which might help, but the answers seem quite inelegant - is there a good object-oriented approach for my case?

3

There are 3 best solutions below

3
On BEST ANSWER

Is that possible somehow or is there another way to achieve a similar behavior?

There is a way to achieve a similar behavior.

Step 1: Set a flag on instance/MySimulation.

Step 2: Check the flag and return self in Quantity.__get__ if the flag is set.

Naive implementation(s)

4 lines change.

class Quantity(object):

    def __init__(self, initval=None):
        self.value = initval

    def __get__(self, instance, owner):
        if hasattr(instance, '_update_context'):  # 1
            return self                           # 2
        return self.value

    def __set__(self, instance, value):
        self.value = value

    def update(self, parent):
        self.value += parent.increment  # Example update using value from parent


class MySimulation(object):
    "this default simulation has only density"
    density = Quantity()
    increment = 1

    def __init__(self, value):
        self.density = value

    def update(self):
        setattr(self, '_update_context', None)  # 3
        self.density.update(self)
        delattr(self, '_update_context')        # 4

Note that this is quite intrusive to MySimulation and its subclasses.
One way to mitigate this is to define an _update method for subclasses to override:

def update(self):
    setattr(self, '_update_context', None)  # 3
    self._update()
    delattr(self, '_update_context')        # 4

def _update(self):
    self.density.update(self)

More robust implementation

Using a metaclass, we can do with 3 lines change to the original code.

class UpdateHostMeta(type):
    UPDATE_CONTEXT_KEY = '_update_context'

    def __init__(cls, name, bases, attrs):
        super().__init__(name, bases, attrs)
        __class__.patch_update(cls)

    @staticmethod
    def patch_update(update_host_class):
        _update = update_host_class.update

        def update(self, *args, **kwargs):
            try:
                setattr(self, __class__.UPDATE_CONTEXT_KEY, None)
                _update(self, *args, **kwargs)
            finally:
                delattr(self, __class__.UPDATE_CONTEXT_KEY)

        update_host_class.update = update

    @staticmethod
    def is_in_update_context(update_host):
        return hasattr(update_host, __class__.UPDATE_CONTEXT_KEY)
class Quantity(object):

    def __init__(self, initval=None):
        self.value = initval

    def __get__(self, instance, owner):
        if UpdateHostMeta.is_in_update_context(instance):  # 1
            return self                                    # 2
        return self.value

    def __set__(self, instance, value):
        self.value = value

    def update(self, parent):
        self.value += parent.increment  # Example update using value from parent


class MySimulation(object, metaclass=UpdateHostMeta):  # 3
    "this default simulation has only density"
    density = Quantity()
    increment = 1

    def __init__(self, value):
        self.density = value

    def update(self):
        self.density.update(self)
3
On

Given the different use cases descriptors allow (possible invokation bindings https://docs.python.org/3/reference/datamodel.html?highlight=descriptor%20protocol#invoking-descriptors), so harder to understand and to maintain, I'd recommend to use the property approach, if the descriptor protocol is not really needed.

You might also consider the dataclasses module, if the focus is more on keeping values than providing functionality.

I hope the following interprets your intention more or less correctly.

import numpy as np

LEN = 5

AS_PROPERTY = True  # TODO remove this line and unwanted ``Quantity`` implementation
if AS_PROPERTY:
    class Quantity:
        def __init__(self, value=None):
            self._val = value

        def getx(self):
            return self._val

        def setx(self, value):
            self._val = value

        def __repr__(self):
            return f"{self._val}"

        value = property(getx, setx)
else:
    class Quantity:  # descriptor, questionable here
        def __init__(self, value=None):
            self._val = value

        def __get__(self, instance, owner):
            return self._val

        def __set__(self, instance, value):
            self._val = value

        def __repr__(self):
            return f"{self._val}"


class Density(Quantity):
    def update(self, owner):
        idx = owner.time % len(self._val)  # simulation time determines index for change
        self._val[idx] += 0.01


class Temperature(Quantity):
    def update(self, owner):
        idx = owner.time % len(self._val)
        self._val[idx] += 1.0


class MySimulation:  # of density
    time_increment = 1

    def __init__(self, value):
        self.time = 0
        self.density = Density(value)

    def __repr__(self):
        return f"{self.density}"

    def time_step(self):
        self.time += MySimulation.time_increment

    def update(self):
        self.density.update(self)


class MySimulation2(MySimulation):  # of density and temperature
    def __init__(self, density_value, temperature_value):
        super().__init__(density_value)
        self.temperature = Temperature(temperature_value)

    def update(self):
        super().update()
        self.temperature.update(self)


if __name__ == '__main__':
    sim = MySimulation(np.arange(5.))
    sim.update()  # => [0.01, 1., 2., 3., 4.]
    print(f"sim: {sim}")

    sim2 = MySimulation2(np.linspace(.1, .5, LEN), np.linspace(10., 50., LEN))
    print(f"sim2:")
    for _ in range(2 * LEN + 1):
        print(f"{sim2.time:2}| D={sim2}, T={sim2.temperature}")
        sim2.update()
        sim2.time_step()
0
On

I would use @aaron's Naive solution, but without additional flags and with __set_name__ rather than __init__ since 1)__init__ is only called in the class definition, so initval is always None, 2) you probably want to store the data in the instance, not in the descriptor.

Just remember that the descriptor object is owned by the class, not by the instance: the 'owner' parameter refers to the class where the descriptor object is created. To get access to the descriptor object methods you have to bypass its __get__ method.

class Quantity(object):

    def __set_name__(self, owner, name):
    # the name of the private attribute in the 'parent' instance 
        self._name = '_'+name

    def __get__(self, instance, owner):
        if instance is None:  # if called from class
            return self       # return Quantity object
        return getattr(instance, self._name) # return value stored in the instance

    def __set__(self, instance, value):
        setattr(instance, self._name, value) # store the value in the instance

    def update(self, instance):
        # Example update using value from parent instance. It should be the same instance as above.
        getattr(instance, self._name) += instance.increment  

class MySimulation(object):
    "this default simulation has only density"
    density = Quantity()
    increment = 1

    def __init__(self, value):
        self.density = value

    def update(self):
        self.__class__.density.update(self)