1

Given a 'contract' of sorts that I want to implement, I want the code to

  1. tell the reader what the intent is
  2. allow the type checker to correct me (fragile base class problem)

E.g. in C++, you can

class X: public Somethingable {
  int get_something() const override
  { return 10; }
};

Now when I rename Somethingable::get_something (to plain something for instance), the compiler will error on my X::get_something because it is not an override (anymore).

In C# the reader gets even more information:

class X : Somethingable {
  int GetSomething() implements Somethingable.GetSomething { return 10; }
}

In Python, we can use abc.ABC and @abstractmethod to annotate that subclasses have to define this and that member, but is there a standardised way to annotate this relation on the implementation site?

class X(Somethingable):
  @typing.implements(Somethingable.get_something) # does not exist
  def get_something(self):
     return 10
xtofl
  • 40,723
  • 12
  • 105
  • 192
  • 1
    Have a look at [zope.interface](https://zopeinterface.readthedocs.io/en/latest/README.html#declaring-interfaces), it may help with this (I'm not sure, don't use it actively, but it was designed to imitate class interfaces in python). If not - you can always write this decorator and metaclass to check correctness, it will be like 100 lines (approx), feel free to ping me for some hints, if needed. – STerliakov May 20 '22 at 10:02
  • That would have made a great answer. – xtofl May 20 '22 at 10:04
  • It seems to! Thanks. – xtofl Nov 21 '22 at 12:29

2 Answers2

2

I was overestimating the complexity of such solution, it is shorter:

import warnings

def override(func):
    if hasattr(func, 'fget'):  # We see a property, go to actual callable
        func.fget.__overrides__ = True
    else:
        func.__overrides__ = True
    return func


class InterfaceMeta(type):
    def __new__(mcs, name, bases, attrs):
        for name, a in attrs.items():
            f = getattr(a, 'fget', a)
            if not getattr(f, '__overrides__', None): continue
            f = getattr(f, '__wrapped__', f)
            try:
                base_class = next(b for b in bases if hasattr(b, name))
                ref = getattr(base_class, name)
                if type(ref) is not type(a):
                    warnings.warn(f'Overriding method {name} messes with class/static methods or properties')
                    continue
                if _check_lsp(f, ref):
                    warnings.warn(f'LSP violation for method {name}')
                    continue
            except StopIteration:
                warnings.warn(f'Overriding method {name} does not have parent implementation')
        return super().__new__(mcs, name, bases, attrs)

override decorator can mark overriding methods, and InterfaceMeta confirms that these methods do exist in superclass. _check_lsp is the most complex part of this, I'll explain it below.

What is actually going on? First, we take a callable and add an attribute to it from the decorator. Then metaclass looks for methods with this marker and:

  • confirms, that at least one of base classes implements it
  • checks, that property remains property, classmethod remains classmethod and staticmethod remains staticmethod
  • checks, that implementation does not break Liskov substitution principle.

Usage

def stupid_decorator(func):
    """Stupid, because doesn't use `wrapt` or `functools.wraps`."""
    def inner(*args, **kwargs):
        return func(*args, **kwargs)
    return inner

class IFoo(metaclass=InterfaceMeta):
    def foo(self): return 'foo'
    @property
    def bar(self): return 'bar'
    @classmethod
    def cmethod(cls): return 'classmethod'
    @staticmethod
    def smethod(): return 'staticmethod'
    def some_1(self): return 1
    def some_2(self): return 2

    def single_arg(self, arg): return arg
    def two_args_default(self, arg1, arg2): return arg1
    def pos_only(self, arg1, /, arg2, arg3=1): return arg1
    def kwonly(self, *, arg1=1): return arg1

class Foo(IFoo):
    @override
    @stupid_decorator  # Wrong signature now: "self" not mentioned. With "self" in decorator won't fail.
    def foo(self): return 'foo2'
 
    @override
    @property
    def baz(self): return 'baz'

    @override
    def quak(self): return 'quak'

    @override
    @staticmethod
    def cmethod(): return 'Dead'

    @override
    @classmethod
    def some_1(cls): return None

    @override
    def single_arg(self, another_arg): return 1

    @override
    def pos_only(self, another_arg, / , arg2, arg3=1): return 1

    @override
    def two_args_default(self, arg1, arg2=1): return 1

    @override
    def kwonly(self, *, arg2=1): return 1

This warns:

LSP violation for method foo
Overriding method baz does not have parent implementation
Overriding method quak does not have parent implementation
Overriding method cmethod messes with class/static methods or properties
Overriding method some_1 messes with class/static methods or properties
LSP violation for method single_arg
LSP violation for method kwonly

You can set the metaclass on Foo as well with the same result.

LSP

LSP (Liskov substitution principle) is a very important concept that, in particular, postulates that any parent class can be substituted with any child class without interface incompatibilities. _check_lsp performs only the very simple checking, ignoring type annotations (it is mypy area, I won't touch it!). It confirms that

  • *args and **kwargs do not disappear
  • positional-only args count is same
  • all parent's regular (positional-or-keyword) args are present with the same name, do not lose default values (but may change) and all added have defaults
  • same for keyword-only args

Implementation follows:

from inspect import signature, Parameter
from itertools import zip_longest, chain

def _check_lsp(child, parent):
    child = signature(child).parameters
    parent = signature(parent).parameters

    def rearrange(params):
        return {
            'posonly': sum(p.kind == Parameter.POSITIONAL_ONLY for p in params.values()),
            'regular': [(name, p.default is Parameter.empty) 
                        for name, p in params.items() 
                        if p.kind == Parameter.POSITIONAL_OR_KEYWORD],
            'args': any(p.kind == Parameter.VAR_POSITIONAL
                        for p in params.values()),
            'kwonly': [(name, p.default is Parameter.empty) 
                       for name, p in params.items() 
                       if p.kind == Parameter.KEYWORD_ONLY],
            'kwargs': any(p.kind == Parameter.VAR_KEYWORD 
                          for p in params.values()), 
        }
    
    child, parent = rearrange(child), rearrange(parent)
    if (
        child['posonly'] != parent['posonly'] 
        or not child['args'] and parent['args'] 
        or not child['kwargs'] and parent['kwargs']
    ):
        return True


    for new, orig in chain(zip_longest(child['regular'], parent['regular']), 
                           zip_longest(child['kwonly'], parent['kwonly'])):
        if new is None and orig is not None:
            return True
        elif orig is None and new[1]:
            return True
        elif orig[0] != new[0] or not orig[1] and new[1]:
            return True
STerliakov
  • 4,983
  • 3
  • 15
  • 37
  • I _think_ you've written the basis for a neat python package. – xtofl May 21 '22 at 13:53
  • @xtofl I just found [this](https://pypi.org/project/overrides/) accidentally. Your idea was not original as well as my implementation:( – STerliakov Jun 03 '22 at 17:57
1

This is a duplicate question, of In Python, how do I indicate I'm overriding a method? That question has an answer by @mkorpela , where he created a pip installable package overrides, that handles this, https://github.com/mkorpela/overrides

Basically, annotated a method with @override

from overrides import override

class Foo
    @override
    def some_method(self):
        ...
Hugh Perkins
  • 7,975
  • 7
  • 63
  • 71