3

I'm trying to write an abstract base class A which will have an abstract method run which the user/developer will be expected to overload. I would like to enforce some 'after' behaviour to be automatically applied to the derived class B, so that after B.run() has run it's course, another standard method will be called (in data pipelines this could e.g. commit or rollback transactions). Is there a way to achieve this?

My failed naive attempt at this is:

def do_the_other_thing(func): 
    def wrapper(): 
        func() 
        print('doing the other thing!')
    return wrapper 

class A: 
    @do_the_other_thing 
    def run(self): 
        pass     

class B(A): 
    def run(self): 
        print('running!') 

B().run() 
>>> 'running!' 
>>> #'doing the other thing!'     # <--- is there a way to get this?

I can of course implement a workaround by creating a different abstract method (e.g. _run) which is called from a non-abstract method A.run, but this is less elegant.

I can see that way back in 2007 PEP 3124 specified exactly this functionality but I can't find any modern reference to it.

kd88
  • 1,054
  • 10
  • 21
  • Decorators will replace your function at the time that it is defined, so I don't think there's any clean way to know that your function has been decorated (so that you can apply it to subclasses) without making a "mark" in some fashion; i.e. adding an attribute to the function or the class to read later. You could use `__init_subclass__` to loop through all the methods defined on a subclass and then apply the decorator to methods with the mark. I guess you'd also have to save which decorator to use, maybe the mark can be the decorator itself. – alkasm May 05 '20 at 14:55
  • 1
    Metaclass would be easier, you could have your metaclass run through the methods a class defines and wrap ones meeting some criteria (e.g. named `run`) and that will happen for each subclass as well. See [here](https://stackoverflow.com/questions/11349183/how-to-wrap-every-method-of-a-class) for e.g. And using metaclasses will also allow you to use `abc.abstractmethod` to enforce the user does override the correct method when they instantiate a class (otherwise it only errors on calling the method), see [here](https://stackoverflow.com/q/31201706/5087436). – alkasm May 05 '20 at 14:58

2 Answers2

4

You actually can not do what you want with a function decorator alone if you do not expect the user to decorate run themselves. You can use class decorators, __init_subclass__, or metaclasses.


With a class decorator,

class A:
    def run(self):
        pass

def do_the_other_thing(func):
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)
        print('doing the other thing!')
    return wrapper


def pipeline_thing(cls):
    cls.run = do_the_other_thing(cls.run)
    # do some other work
    return cls


@pipeline_thing
class B(A):

    def run(self):
        print("running!")

Or with __init_subclass__

class A:
    def run(self):
        pass

    def __init_subclass__(cls):
        super().__init_subclass__()
        cls.run = do_the_other_thing(cls.run)
        # do some other work

class B(A):

    def run(self):
         print("running!")

Or with metaclasses

class AMeta(type):

    def __init__(cls, name, bases, attrs, **kwargs):
        super().__init__(name, bases, attrs)
        cls.run = do_the_other_thing(cls.run)
        # do some other work

class A(metaclass=AMeta):
    def run(self):
        pass

class B(A):

    def run(self):
        print("running!")

This example is overkill for metaclasses (you are using metaclass.__init__ - the least powerful magic method in a metaclass and your behavior can be done with __init_subclass__ (this is the intended use of __init_subclass__). Using a metaclass in this way will prevent your users from using metaclasses and it will unnecessarily complicate your code. If you need the pipeline to do more magic, you can use them (say if you need access to __new__).

I would either use __init_subclass__ or a class decorator (@pipe or something) that also presumably mixed B with A. As, alkasm mentioned, you can make A inherit from abc.ABC and decorate run with abc.abstractmethod to ensure subclasses implement it.

modesitt
  • 7,052
  • 2
  • 34
  • 64
  • 2
    I agree, I think `__init__subclass__` is the way to go here, as it makes the most sense and is the cleanest way to approach any sort of children of abstract class initialization/changes in the parent – Zionsof May 05 '20 at 15:22
2

Don't override run; override a method that run calls.

class A:
    def run(self):
        self.do_run()
        print('doing the other thing!')

    def do_run(self):
        pass


class B(A):
    def do_run(self):
        print('running!') 

Then

>>> B().run() 
running!
doing the other thing!
chepner
  • 497,756
  • 71
  • 530
  • 681
  • 1
    I agree - but I think OP wanted to avoid this as they said in their question. This is the way to do it for simplicity (which is always good). – modesitt May 05 '20 at 15:15