Search code examples
pythonooppython-descriptors

how to add methods to descriptors or properties in python


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?


Solution

  • 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)