Skip to main content
1 of 5
Dan Oberlam
  • 7.9k
  • 2
  • 32
  • 74

I don't think that what you're doing is good style. I think you're adding a lot of extra complexity and places for things to go wrong, or become hard to understand, or whatever. I don't think you lose anything by just making very small methods that call super and your special method.

With that out of the way, here is (what I propose) is a better and safer way to do it


What you're trying to do is (semi-automatically) change how certain data elements are retrieved and executed when called. To me, this sounds like a descriptor. Descriptors allow you to override how you set, get, or delete an attribute on an object, like so (example taken from above documentation link):

class RevealAccess(object):
    """A data descriptor that sets and returns values
       normally and prints a message logging their access.
    """

    def __init__(self, initval=None, name='var'):
        self.val = initval
        self.name = name

    def __get__(self, obj, objtype):
        print('Retrieving', self.name)
        return self.val

    def __set__(self, obj, val):
        print('Updating', self.name)
        self.val = val

>>> class MyClass(object):
...     x = RevealAccess(10, 'var "x"')
...     y = 5
...
>>> m = MyClass()
>>> m.x
Retrieving var "x"
10
>>> m.x = 20
Updating var "x"
>>> m.x
Retrieving var "x"
20
>>> m.y
5

For yours, what you need is a descriptor that auto-calls super for you. Note - its important to use super to handle cases of multiple-inheritance; as pointed out in the comments, your current implementation will only ever call the deque method directly, instead of taking the time to deal with __mro__. In the implementation below, we:

  1. Use the __get__ method to automatically get the class (objtype) and self (obj) of the instance (not the descriptor). This provides enough information for us to call super().
  2. Use getattr to get the attribute in question from the result of super, and lets us ignore the complexities of __mro__.
  3. Create a wrapper function (don't forget to use functools.wraps) that calls the wrapped function with the appropriate arguments, and then the extra function you specified.
  4. __get__ returns the wrapped function, and now MyClass.<<attr_name>> will be that function
from functools import wraps

class AutoSuperDescriptor(object):
    def __init__(self, attr_name, extra):
        self.attr_name = attr_name
        self.extra = extra

    def __get__(self, obj, objtype):
        wrapped_function = getattr(
            super(objtype, obj), self.attr_name
        )
        extra_bits = self.extra

        @wraps(wrapped_function)
        def wrapper(yourself, *args, **kwargs):
            result = wrapped_function(
                yourself, *args, **kwargs
            )
            extra_bits(yourself)
            return result

        return wrapper

You create your descriptor by providing enough information to find the overwritten attribute, as well as the function we want to add on to the end. For example, you might do this:

class FileMirroredDeque(deque):
    append = AutoSuperDescriptor("append", updates_file)

If that meets your need then you're done.


But wait, there's more

Its pretty tedious and error-prone to do this by hand for each method you want to apply this to. Wouldn't it be nice if there was a way to just tie into the creation of a class and have it do this for us?

There is, and the answer is metaclasses. Metaclasses are a really complex, and really powerful, tool in the Python toolbox. Anytime you find yourself using them you should think really, really hard about if that is the best way to do it. I don't think this is one of those times (see start of this answer), but if you wanted to do that, I think we'd have to do something like this:

  1. Define a way to identify the elements that should be overridden at class initialization
  2. Define a way to identify what extra behavior needs to be added to them
  3. Add the behavior.

The third one is easy - we implemented it with our descriptor up above.

The first two are a little more complicated, and have a few options. We could choose between adding kwargs to the class initialization:

class FileMirroredDeque(
    deque, 
    metaclass=SuperChargerMetaclass, 
    methods_to_supercharge = [""], 
    method_to_supercharge_with = "method_name"
):

I think this would get clunky and obscure things. Instead, I'm going to propose that we define a special attribute (I used the __<<name>>__ naming convention, which is a no-no, but I thought made it clearer for demonstrative purposes) to list the attributes to override, and then set an attribute on the extra method itself to find it. The final implementation of the class looks like so:

def supercharger(func):
    func.__is_supercharger = True
    return func

class FileMirroredDeque(
    deque, metaclass=SuperChargerMetaClass
):
    __supercharge__ = [
        "__delitem__",
        "__iadd__",
        "__imul__",
        "__setitem__",
        "append",
        "appendleft",
        "extend",
        "extendleft",
        "insert",
        "pop",
        "popleft",
        "remove",
        "reverse",
        "rotate",
    ]


    @supercharger
    def _update_file(self):
        with open(self.path, "w") as f:
            json.dump(list(self), f, indent=self._indent)

    def __init__(
        self,
        cache_path,
        maxlen=None,
        clean=False,
        file_indent=None,
    ):
        super().__init__((), maxlen)

        self.path = cache_path
        self._indent = file_indent
        self._bak_file = None

        if clean:
            self._update_file()  # Overwrite contents of file.
            return  # Don't import data from file.

        with open(
            self.path, "a+"
        ) as f:  # Creates file if it doesn't exist
            f.seek(
                0, 0
            )  # Seek back to beginning of file for json decode.
            contents = f.read()

        try:
            contents = json.loads(contents or "[]")
            self.extend(
                contents
            )  # initializes internal list with persistent data or empty list.

        except json.decoder.JSONDecodeError:
            new_file = (
                os.path.basename(self.path)
                + datetime.datetime.utcnow().strftime(
                    "_%Y_%m_%d_%H_%M_%S"
                )
                + ".bak"
            )

            new_path = os.path.join(
                os.path.dirname(self.path), new_file
            )

            print(
                f"File: {self.path} was not valid JSON. It will be copied to {new_path} and a new "
                f"file will be created."
            )

            shutil.copyfile(self.path, new_path)
            self._bak_file = new_path

But how do we actually get this to happen? Like so!

class SuperChargerMetaClass(type):
    _SUPERCHARGER_ATTRNAME = "__supercharge__"

    @classmethod
    def _get_supercharger(cls, klass_attrs):
        return next(
            method
            for name, method in klass_attrs.items()
            if getattr(method, "__is_supercharger", False)
        )

    def __init__(cls, name, bases, attrs):
        supercharger = cls._get_supercharger(attrs)
        superchargees = attrs.get(
            cls._SUPERCHARGER_ATTRNAME, ()
        )

        for method_to_supercharge in superchargees:

            attrs[
                method_to_supercharge
            ] = AutoSuperDescriptor(
                method_to_supercharge, supercharger
            )

        return super().__init__(name, bases, attrs)

In the __init__ method (__new__ would probably work too, but I ended up getting this to work as __init__ so I left it, we get the "supercharger" method to append to our functions, and our list of "superchargees" that need the behavior added to them. From there, its just a matter of making the relevant attribute look to our descriptor instead of whatever (if anything) was there before.

Dan Oberlam
  • 7.9k
  • 2
  • 32
  • 74