E. Paval
E. Paval

Reputation: 69

Is there a way to tell (intercept) modification of a class attribute?

New to Python, trying to solve a problem where I need to implement a class decorator that will keep track of changes to its class and instance attributes. The decorator needs to add a get_change attribute to all class and instance attributes to track their status (INIT, MODIFIED, DELETED) corresponding to initial value, modified value and deleted attribute. For the most part I solved it with the exception of one edge case: modifying, deleting a class attribute.

@change_detection
class Struct(object):
    x = 42

    def __init__(self, y=0):
        self.y = y

a = Struct(11)

a.x == Struct.x == 42 # True
a.y == 11 # True

a.x.get_change == Struct.x.get_change == "INIT" # True
a.y.get_change == "INIT" # True

a.x = 100
a.x.get_change == "MOD" # True

del a.x
a.x.get_change == "DEL" # True

I am stuck with the class attribute like changes:

Struct.x = 10
Struct.x.get_change == "MOD" # False - I don't know how to intercept setting the class attribute
del Struct.x
Struct.x.get_change == "DEL" # False - same as before

So, how do you intercept class setting, deleting of attributes? For the instance level you have __setattr__ and __delattr__, but what is the equivalent for class level, if any?

Thank you!

Upvotes: 0

Views: 380

Answers (2)

E. Paval
E. Paval

Reputation: 69

Thanks to pointers from @Felipe, managed to solve all issues. Again, this is not a practical problem, rather is the code challenge described here.

The idea was to subclass dynamically the decorated class and return proxy objects containing the get_change attribute in addtion to the attributes of the proxied object.

def change_detection(cls):
    class NonExistentAttribute(object):
        pass

    class JNoneMeta(type):
        def __subclasscheck__(currentCls, parentCls):
            return currentCls == JNone and parentCls == type(None)

    class JBoolMeta(type):
        def __subclasscheck__(currentCls, parentCls):
            return currentCls == JBool and parentCls == type(bool)

    class JInt(int):
        pass

    class JString(str):
        pass

    class JBool(object, metaclass = JBoolMeta):
        def __init__(self, value):
            self._value = value

        def __bool__(self):
            return type(self._value) == type(bool) and self._value

        def __eq__(self, value):
            return self._value == value

    class JNone(object, metaclass = JNoneMeta):
        def __bool__(self):
            return False

        def __eq__(self, value):
            return value == None

    class Journaled(cls):
        @staticmethod
        def createAttribute(value, state):
            if value == None:
                value = JNone()
            elif isinstance(value, bool):
                value = JBool(value)
            elif isinstance(value, int):
                value = JInt(value)
            elif isinstance(value, str):
                value = JString(value)

            try: # for functions/methods but allows for lambda
                value.get_change = state
            except AttributeError:
                pass

            return value

        def __init__(self, *args, **kwargs):
            super().__setattr__("__modified__", set())
            super().__setattr__("__deleted__", set())
            super().__init__(*args, **kwargs)

        def __getattribute__(self, name):
            try:
                v = super().__getattribute__(name)
            except AttributeError:
                v = NonExistentAttribute()

            if not name.startswith("__"):
                if name in self.__deleted__:
                    s = "DEL"
                elif name in self.__modified__:
                    s = "MOD"
                else:
                    s = "INIT" if type(v) != NonExistentAttribute else ""
                return Journaled.createAttribute(v, s)

            return v

        def __setattr__(self, name, value):
            if not name.startswith("__") or name not in self.__modified__:
                try:
                    v = self.__getattribute__(name)
                    if type(v) != NonExistentAttribute and (v != value or typesAreDifferent(type(v), type(value))): 
                        self.__modified__.add(name)
                except AttributeError:
                    pass
            super().__setattr__(name, value)

        def __delattr__(self, name):
            if name in self.__modified__:
                self.__modified__.remove(name)
            if hasattr(self, name):
                self.__deleted__.add(name)
                super().__setattr__(name, None)

    def typesAreDifferent(subClass, parentClass):
        return not (issubclass(subClass, parentClass) or issubclass(parentClass, subClass))

    #copy original class attributes to Journaled class
    for clsAttr in filter(lambda x: not x.startswith("__"), dir(cls)):
        setattr(Journaled, clsAttr, cls.__dict__[clsAttr])

    return Journaled

Upvotes: 1

felipe
felipe

Reputation: 8035

There is no magic method (from my knowledge -- perhaps there is a hack that might work here) that deals with class attributes. You can instead do something like so:

class Verifier:
    def __init__(self, obj):
        self.obj = obj
        self.init = obj.__dict__.copy()

    def get_change(self, var):
        if var not in self.obj.__dict__:
            return "DEL"
        elif self.obj.__dict__[var] == self.init[var]:
            return "INIT"
        elif self.obj.__dict__[var] != self.init[var]:
            return "MOD"

class Struct:
    x = 42

verifier = Verifier(Struct)

This will allow the following:

Struct.x = 42
print(verifier.get_change("x")) # INIT

Struct.x = 43
print(verifier.get_change("x")) # MOD

del Struct.x
print(verifier.get_change("x")) # DEL

However, note that this will break:

Struct.y = 40
print(verifier.get_change("y"))
Traceback (most recent call last):
  File "test.py", line 26, in <module>
    print(verifier.get_change("y"))
  File "test.py", line 9, in get_change
    elif self.obj.__dict__[var] == self.init[var]:
KeyError: 'y'

Since our Verifier only has access to an older Struct that did not have a the y variable.


Edit (3.0): Current progress. Decided to add it here in case you want to check out what I've currently have, as it might help you solve your own issue:

def Proxy(val):
    try:
        class Obj(type(val)): pass
    except:
        class Obj(): pass

    class Proxy(Obj):
        def __init__(self, val):
            self.val = val
            self.old = val

            self.modified = False
            self.deleted = False

        @property
        def get_change(self):
            if type(self.val) == type(NONE):
                return ""
            elif self.deleted:
                return "DEL"
            elif self.val is not self.old or self.modified or self.val != self.old:
                return "MOD"
            elif self.val is self.old  or self.val == self.old:
                return "INIT"

        def __getattr__(self, attr):
            return getattr(self.val, attr)

        def __repr__(self):
            return repr(self.val)

        def __eq__(self, val):
            if self.val == val:
                return True
            else:
                return super(Proxy, self).__eq__(val)

        def __bool__(self):
            if self.val == None:
                return False
            else:
                return not self.val

    return Proxy(val)


def change_detection(cls):

    class cls_new(cls):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)

        def __getattribute__(self, attr):
            return super(cls_new, self).__getattribute__(attr)

        def __getattr__(self, attr):
            return Proxy(NONE)

        def __setattr__(self, attr, val):
            if not attr.startswith("__"):
                value = Proxy(val)

                # Checks if attr in instance dictionary.
                if attr in self.__class__.__dict__:
                    value.old = self.__class__.__dict__[attr].old
                elif attr in self.__dict__:
                    value.old = self.__dict__[attr].old

                    if self.__dict__[attr] != val and val is None:
                        value.modified = True

            else:
                value = val

            super(self.__class__, self).__setattr__(attr, value)

        def __delattr__(self, attr):
            if attr in self.__class__.__dict__:
                self.__class__.__dict__[attr].val = None
                self.__class__.__dict__[attr].deleted = True

            if attr in self.__dict__:
                self.__dict__[attr].val = None
                self.__dict__[attr].deleted = True



    try:
        # Copies class attributes to cls_new.__class__.__dict__ as Proxy objects.
        for attr in dir(cls()):
            if not callable(getattr(cls(), attr)) and not attr.startswith("__") and attr in cls.__dict__:
                setattr(cls_new, attr, Proxy(cls.__dict__[attr]))

        for attr in dir(cls):
            if not attr.startswith("__") and callable(cls.__dict__[attr]) and cls.__dict__[attr].__name__ == (lambda: 0).__name__:
                setattr(cls_new, attr, Proxy(cls.__dict__[attr]))
    except:
        pass

    return cls_new

Upvotes: 1

Related Questions