0

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!

wjandrea
  • 28,235
  • 9
  • 60
  • 81
E. Paval
  • 69
  • 4
  • 3
    Do you *really* need that API? Because `a.x` is an `int`, and `int` objects don't have a `get_change` attribute themselves. If you really want it to work like that, you'll need to wrap the int in some kind of proxy class that lets you add the `get_change` property without effecting its other behaviors. If you could use a different API (like `a.get_change('x')`, it would be a lot easier. – Blckknght Apr 17 '20 at 22:36
  • I'm kinda new to this myself, but I think you want a metaclass – wjandrea Apr 17 '20 at 22:49
  • @Blckknght When I was implementing my answer I thought the same thing, but the issue is that when the proxy class is deleted, you end up without the ability to call `get_change`. I did contemplate overriding the `__del__` method to "cache" deleted variables, but I didn't try that route. Another way would be to always return "DEL" when `__getattr__` is called, and the variable does not exist in the proxy class. – felipe Apr 17 '20 at 23:05
  • 2
    @OP This sounds like a [XY Problem](http://xyproblem.info/). I believe you are better off thinking of a new (improved + safe) design to implement your goal, then to try to mess with class attributes for this specific case. You could always create an `Attr` object that holds all of your intended class attributes, and pass it to classes that will have use for it -- the `Attr` object can be intialized via `**kwargs` as mentioned [here](https://stackoverflow.com/questions/8187082/how-can-you-set-class-attributes-from-variable-arguments-kwargs-in-python). – felipe Apr 17 '20 at 23:10
  • @Blckkght It's not that I need it, it's a code challenge on codewars.com. I think I got all nailed down, including builtins (I am subclassing and/or wrapping for None and bool). This is the only thing I am not sure how to solve. For anybody curious about the challenge: https://www.codewars.com/kata/56e02d5f2ebcd50083001300/train/python – E. Paval Apr 18 '20 at 01:36
  • @wjandrea - I don't think the problem would be any different though, besides, the requirement is to implement the decorator change_detection – E. Paval Apr 18 '20 at 01:51
  • @Felipe - didn't know about XY problem had a name, much less it is formalized; however this is not the case as this is a code challenge and it is defined pretty strict. When I started implementing a solution I went with copying all class attributes in self's dictionary, soon after I discovered this is not going to work because it you cannot use them with class.attribute syntax. – E. Paval Apr 18 '20 at 02:00
  • @E.Paval Read through the prompt. Very interesting problem indeed. I'll tackle it after my finals at Uni, and I'll post solution here when done. – felipe Apr 18 '20 at 21:29
  • None of the tests seem to actually change class attributes on the class. It looks like they want you to handle attributes defined on the class, but not actually changing attributes on the class or detecting such changes. – user2357112 Apr 19 '20 at 00:17

2 Answers2

1

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
felipe
  • 7,324
  • 2
  • 28
  • 37
  • Thanks but the problem is inflexible in its syntax. It has to be: obj.x.get_change or class.x.get_change and change is an attribute (or property) – E. Paval Apr 18 '20 at 01:45
  • @E.Paval I've edited the post above and added the code I currently have. The question is _very_ interesting. Currently getting 20/40 tests. The other 20 tests are failing because `None` and `bool` types cannot be inherited. Not sure how one would fix this -- any ideas on your end? Very interested in solving this question now. In the editted code you will see the answer to your original question -- but now stuck at how to work around `None` and `bool` types. – felipe Apr 18 '20 at 23:14
  • Made another edit. Figured out how to deal with `None` and `bool`. Now getting 35/40 test cases. – felipe Apr 18 '20 at 23:35
  • Made another edit. All tests are passing except random tests. If you can figure it out, please lmk! :) – felipe Apr 19 '20 at 00:12
  • Made another edit. 137 tests passing, 2 tests failing due to `lambda` function. – felipe Apr 19 '20 at 01:03
  • Made another edit. `lambda` is now taken care of, but random issue with `MOD` and `INIT`. – felipe Apr 19 '20 at 02:17
  • I figured out a way to deal with class attributes and I just need to put everything together. Thank you so much for pointers, like the way you create the classes on the fly. I used a similar approach by dynamically subclassing the given class with type() to build a proxy.. Bool and NONE are given in the testing code for you to use but I see you already cam up with a solution. Will definitely share my solution after I put it together and pass the tests. As I said I am new to Python and to dynamically interpreted languages/ – E. Paval Apr 19 '20 at 14:01
  • Glad something here was of help. Would love to see if/when you figure out how to solve the problem. I eventually switched over to `NONE`, `Bool`, and `NO_SUCH`, but I'm still having some issues on my `get_change` when it comes to `None`'s. – felipe Apr 19 '20 at 21:09
1

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
E. Paval
  • 69
  • 4
  • Nice stuff man!! I'm going to study your solution and continue the implementation on mine. I still have 2 bugging errors. – felipe Apr 22 '20 at 02:01