0

I had an idea for a simple decorator that would probably not be very useful (feel free to comments on that, but it's not my main focus here). Regardless of that, I think it'd show how to achieve certain things and these are my main interest.

The idea: A decorator @inherit_docs that would simply assign method's __doc__ from the one with the same name in the first parent of the class that has it.

For example, let's say that we have this:

class A:

    def foo(self):
        """
        Return something smart.
        """
        return ...

Now, I want this:

class B(A):

    @inherit_docs
    def foo(self):
        # Specifically for `B`, the "smart" thing is always 17,
        # so the docstring still holds even though the code is different.
        return 17

to be equivalent to this:

class B(A):

    def foo(self):
        """
        Return something smart.
        """
        # Specifically for `B`, the "smart" thing is always 17,
        # so the docstring still holds even though the code is different.
        return 17

One purpose of this could be to have a base class that declares some functionality and then its inherited classes implement this functionality for different types of data (polymorphism). The methods are doing the same thing, so automating docstrings consistency would be nice. help itself deals with it ok, but B.foo.__doc__ does not (B.foo without a docstring has that value as None).

The problems here (and the main reasons why I am interested in it) are:

  1. How can inherit_docs know which class it is in (so, how can it now about B)?
  2. How can it get anything about the class (in this case, it's parents) when the class itself doesn't exist at the moment when @inherit_docs is executed?

I could likely MacGyver something with metaclasses, but that would mean that the decorator works only on the classes that inherit some class made specifically for this purpose, which is ugly.

I could also have it do nothing when @inherit_docs is called, but then do something when foo itself is called. This would normally work fine (because the call would get self and then play with it or with type(self)), but that won't help in this case, as B.foo.__doc__ or help(B.foo) do not call foo itself.

Is this even possible and, if so, how?

Vedran Šego
  • 3,553
  • 3
  • 27
  • 40
  • You can specify the method you are overriding expclitily. `@inherit_docs(A.foo) def foo(...): ...`. Note that this is basically a special case of `functools.update_wrapper`, where the attributes that get updated are *by default* specified by `functools.WRAPPER_ASSIGNMENTS`, but you can pass `)('__doc__', )` as an explicit argument. – chepner Mar 22 '23 at 13:04
  • Whatever you are thinking of with a metaclass, you can probably accomplish by defining `A.__init_subclass__` instead. – chepner Mar 22 '23 at 13:05
  • @matszwecja, looks promissing. I'll try to get it to work with this. Thank you. – Vedran Šego Mar 22 '23 at 19:53
  • @matszwecja, yes, this was spot on! Thank you! – Vedran Šego Mar 22 '23 at 23:14

2 Answers2

1

We can use mro() to look up B's parent(s) and then search through dict for some_method's name (i.e. 'foo' but for class A), get the docstring, then assign some_method's __doc__ in the decorator wrapper to be that of the parent:

def inherit_docs(some_method):
    def wrapper(self):
        a = self.__class__.mro()
        wrapper.__doc__ = a[-2].__dict__[some_method.__name__].__doc__
        return some_method(self)
    return wrapper

To "walk through" the method resolution order and find the "last" parent (i.e. assuming there are classes C which inherits from B, D which inherits from C, ... all or some of which contain some_method named 'foo' and we're using this decorator in class C, D, ... and want to assign its __doc__ to be that of class A) then:

def inherit_docs(some_method):
    def wrapper(self):
        a = self.__class__.mro()
        a = [value for index, value in enumerate(a) if value.__dict__.__contains__(some_method.__name__) and index != 0][-1]
        wrapper.__doc__ = a.__dict__[some_method.__name__].__doc__
        return some_method(self)
    return wrapper
Ori Yarden PhD
  • 1,287
  • 1
  • 4
  • 8
  • Use the MRO, not `__bases__`; for all you know, the class whose method you are overriding is further up the inheritance tree than just the first of the immediate parents. – chepner Mar 22 '23 at 17:25
  • Also, don't assume it's any specific element of `a`; you should walk the MRO, find the first one that *has* a method by that name (because that's what's actually being overriden), and use its docstring. – chepner Mar 22 '23 at 17:33
  • I can confirm going up the MRO is the way to go. Indeed, it is what is done in the standard library [`inspect.getdoc()`](https://github.com/python/cpython/blob/9b19d3936d7cabef67698636d2faf6fa23a95059/Lib/inspect.py#L859) which falls back to [`_finddoc()`](https://github.com/python/cpython/blob/9b19d3936d7cabef67698636d2faf6fa23a95059/Lib/inspect.py#L797). – paime Mar 22 '23 at 18:19
  • This doesn't work (try it on two classes and print `B.foo.__doc__`), probably because the `__doc__` assignment is done when the method is called, not when the class is created. Although, weirdly enough, it also doesn't work if the method is first called. I need to figure out why not. Btw, the walk would be better if done as `next` on `reversed(a)`, to avoid creating a list. – Vedran Šego Mar 22 '23 at 19:46
  • Ok, I got it. You need `wrapper.__doc__ = ...` instead of `some_method.__doc__ = ...` in order to get it to work after `B.foo()` was called. But, without the call, it just won't work. The would would need to be outside of `wrapper`, but then the class is not available. – Vedran Šego Mar 22 '23 at 19:52
  • Good points @Vedran Šego ! And yeah, if it's not called it won't replace the docstring... no idea what to do about that, was kinda assuming the call would be implied. – Ori Yarden PhD Mar 22 '23 at 20:14
0

The hint by @matszwecja to look at this answer was spot on. Here is the full solution that seems to work:

class inherit_docs:
    def __init__(self, method):
        self._method = method

    def __set_name__(self, owner, name):
        print(f"decorating {self._method} and using {owner}")
        try:
            inherited_method = next(
                inherited_method
                for inherited_method in (
                    parent.__dict__.get(self._method.__name__)
                    for parent in reversed(owner.mro())
                )
                if inherited_method and inherited_method.__doc__ is not None
            )
        except StopIteration:
            pass
        else:
            self.__doc__ = inherited_method.__doc__

    def __call__(self, *args, **kwargs):
        return self._method(self, *args, **kwargs)


class A:
    def foo(self):
        """
        Doctring in A.
        """
        return 19


class B(A):
    @inherit_docs
    def foo(self):
        return 17


print(B.foo.__doc__)
print(B.foo())

The solution could still use wrap in order to have the correct method signature, but that's beyond the scope of this question.

The main difference from the solution to the other question is that the decorator in the other solution replaces itself by the decorated method, whereas this one defines __call__. I decided to go with this approach because it allows stacking of decorators, unlike that other solution.

So, how does this work?

  1. Namespace B is defined (so, "there will be a class B with a method foo"). This is the whole class B:... bit of the code.

  2. Then, when the compiler reaches the end of class B:... code, class B is actually created.

  3. At that point, all attributes of B are checked and __set_name__ is called for any attribute that has it.

  4. Since the call to the decorator @inherit_docs is really just a shortcut for foo = inherit_docs(foo), B.foo is really an instance of inherit_docs class. This instance has __set_name__, and that gets called while class B is being created, and it does all the magic with the docstring (as explained in the answer by @OriYarden; what I did is a simple refactor of that).

  5. Finally, because inherit_docs has __call__, it gets called when B().foo(...) is called, and it simply propagates the call to the original method saved as self._method.

So, in a way, __set_name__ is each attribute's event handler for the event "your class (not an instance of that class!) was just created".

Vedran Šego
  • 3,553
  • 3
  • 27
  • 40