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