Search code examples
pythonclassmetaclassinject

Modify an attribute of an already defined class in Python (and run its definition again)


I am trying to modify an already defined class by changing an attribute's value. Importantly, I want this change to propagate internally.

For example, consider this class:

class Base:
    x = 1
    y = 2 * x

    # Other attributes and methods might follow

assert Base.x == 1
assert Base.y == 2

I would like to change x to 2, making it equivalent to this.

class Base:
    x = 2
    y = 2 * x

assert Base.x == 2
assert Base.y == 4

But I would like to make it in the following way:

Base = injector(Base, x=2)

Is there a way to achieve this WITHOUT recompile the original class source code?


Solution

  • The effect you want to achieve belongs to the realm of "reactive programing" - a programing paradigm (from were the now ubiquitous Javascript library got its name as an inspiration).

    While Python has a lot of mechanisms to allow that, one needs to write his code to actually make use of these mechanisms.

    By default, plain Python code as the one you put in your example, uses the Imperative paradigm, which is eager: whenever an expression is encoutered, it is executed, and the result of that expression is used (in this case, the result is stored in the class attribute).

    Python's advantages also can make it so that once you write a codebase that will allow some reactive code to take place, users of your codebase don't have to be aware of that, and things work more or less "magically".

    But, as stated above, that is not free. For the case of being able to redefine y when x changes in

    class Base:
        x = 1
        y = 2 * x
    

    There are a couple paths that can be followed - the most important is that, at the time the "*" operator is executed (and that happens when Python is parsing the class body), at least one side of the operation is not a plain number anymore, but a special object which implements a custom __mul__ method (or __rmul__) in this case. Then, instead of storing a resulting number in y, the expression is stored somewhere, and when y is retrieved either as a class attribute, other mechanisms force the expression to resolve.

    If you want this at instance level, rather than at class level, it would be easier to implement. But keep in mind that you'd have to define each operator on your special "source" class for primitive values.

    Also, both this and the easier, instance descriptor approach using property are "lazily evaluated": that means, the value for y is calcualted when it is to be used (it can be cached if it will be used more than once). If you want to evaluate it whenever x is assigned (and not when y is consumed), that will require other mechanisms. Although caching the lazy approach can mitigate the need for eager evaluation to the point it should not be needed.

    1 - Before digging there

    Python's easiest way to do code like this is simply to write the expressions to be calculated as functions - and use the property built-in as a descriptor to retrieve these values. The drawback is small: you just have to wrap your expressions in a function (and then, that function in something that will add the descriptor properties to it, such as property). The gain is huge: you are free to use any Python code inside your expression, including function calls, object instantiation, I/O, and the like. (Note that the other approach requires wiring up each desired operator, just to get started).

    The plain "101" approach to have what you want working for instances of Base is:

    class Base:
       x = 1
       @property
       def y(self):
           return self.x * 2
    
    b = Base()
    b.y
    -> 2
    Base.x = 3
    b.y
    -> 6
    
    
    

    The work of property can be rewritten so that retrieving y from the class, instead of an instance, achieves the effect as well (this is still easier than the other approach).

    If this will work for you somehow, I'd recommend doing it. If you need to cache y's value until x actually changes, that can be done with normal coding

    2 - Exactly what you asked for, with a metaclass

    as stated above, Python'd need to know about the special status of your y attribute when calculcating its expression 2 * x. At assignment time, it would be already too late. Fortunately Python 3 allow class bodies to run in a custom namespace for the attribute assignment by implementing the __prepare__ method in a metaclass, and then recording all that takes place, and replacing primitive attributes of interest by special crafted objects implementing __mul__ and other special methods.

    Going this way could even allow values to be eagerly calculated, so they can work as plain Python objects, but register information so that a special injector function could recreate the class redoing all the attributes that depend on expressions. It could also implement lazy evaluation, somewhat as described above.

    from collections import UserDict
    import operator
    
    
    class Reactive:
        def __init__(self, value):
            self._initial_value = value
            self.values = {}
    
        def __set_name__(self, owner, name):
            self.name = name
            self.values[owner] = self._initial_value
    
        def __get__(self, instance, owner):
            return self.values[owner]
    
        def __set__(self, instance, value):
            raise AttributeError("value can't be set directly - call 'injector' to change this value")
    
        def value(self, cls=None):
            return self.values.get(cls, self._initial_value)
    
        op1 = value
    
        @property
        def result(self):
            return self.value
    
        # dynamically populate magic methods for operation overloading:
        for name in "mul add sub truediv pow contains".split():
            op = getattr(operator, name)
            locals()[f"__{name}__"] = (lambda operator: (lambda self, other: ReactiveExpr(self, other, operator)))(op)
            locals()[f"__r{name}__"] = (lambda operator: (lambda self, other: ReactiveExpr(other, self, operator)))(op)
    
    
    class ReactiveExpr(Reactive):
        def __init__(self, value, op2, operator):
            self.op2 = op2
            self.operator = operator
            super().__init__(value)
    
        def result(self, cls):
            op1, op2 = self.op1(cls), self.op2
            if isinstance(op1, Reactive):
                op1 = op1.result(cls)
            if isinstance(op2, Reactive):
                op2 = op2.result(cls)
            return self.operator(op1, op2)
    
        def __get__(self, instance, owner):
            return self.result(owner)
    
    
    class AuxDict(UserDict):
        def __init__(self, *args, _parent, **kwargs):
            self.parent = _parent
            super().__init__(*args, **kwargs)
    
    
        def __setitem__(self, item, value):
            if isinstance(value, self.parent.reacttypes) and not item.startswith("_"):
                value = Reactive(value)
            super().__setitem__(item, value)
    
    
    class MetaReact(type):
        reacttypes = (int, float, str, bytes, list, tuple, dict)
    
        def __prepare__(*args, **kwargs):
            return AuxDict(_parent=__class__)
    
        def __new__(mcls, name, bases, ns, **kwargs):
            pre_registry = {}
            cls = super().__new__(mcls, name, bases, ns.data, **kwargs)
            #for name, obj in ns.items():
                #if isinstance(obj, ReactiveExpr):
                    #pre_registry[name] = obj
                    #setattr(cls, name, obj.result()
            for name, reactive in pre_registry.items():
                _registry[cls, name] = reactive
            return cls
    
    def injector(cls, inplace=False, **kwargs):
        original = cls
        if not inplace:
            cls = type(cls.__name__, (cls.__bases__), dict(cls.__dict__))
        for name, attr in cls.__dict__.items():
            if isinstance(attr, Reactive):
                if isinstance(attr, ReactiveExpr) and name in kwargs:
                    raise AttributeError("Expression attributes can't be modified by injector")
                attr.values[cls] = kwargs.get(name, attr.values[original])
        return cls
    
    class Base(metaclass=MetaReact):
        x = 1
        y = 2 * x
    
    

    And, after pasting the snippet above in a REPL, here is the result of using injector:

    In [97]: Base2 = injector(Base, x=5)
    
    In [98]: Base2.y
    Out[98]: 10