Search code examples
pythonabstract-classstatic-methodsabc

In Python, how to enforce an abstract method to be static on the child class?


This is the setup I want: A should be an abstract base class with a static & abstract method f(). B should inherit from A. Requirements: 1. You should not be able to instantiate A 2. You should not be able to instantiate B, unless it implements a static f()

Taking inspiration from this question, I've tried a couple of approaches. With these definitions:

class abstractstatic(staticmethod):
    __slots__ = ()
    def __init__(self, function):
        super(abstractstatic, self).__init__(function)
        function.__isabstractmethod__ = True
    __isabstractmethod__ = True

class A:
    __metaclass__ = abc.ABCMeta
    @abstractstatic
    def f():
        pass

class B(A):
    def f(self):
        print 'f'

class A2:
    __metaclass__ = abc.ABCMeta
    @staticmethod
    @abc.abstractmethod
    def f():
        pass

class B2(A2):
    def f(self):
        print 'f'

Here A2 and B2 are defined using usual Python conventions and A & B are defined using the way suggested in this answer. Following are some operations I tried and the results that were undesired.

With classes A/B:

>>> B().f()
f
#This should have thrown, since B doesn't implement a static f()

With classes A2/B2:

>>> A2()
<__main__.A2 object at 0x105beea90>
#This should have thrown since A2 should be an uninstantiable abstract class

>>> B2().f()
f
#This should have thrown, since B2 doesn't implement a static f()

Since neither of these approaches give me the output I want, how do I achieve what I want?


Solution

  • You can't do what you want with just ABCMeta. ABC enforcement doesn't do any type checking, only the presence of an attribute with the correct name is enforced.

    Take for example:

    >>> from abc import ABCMeta, abstractmethod, abstractproperty
    >>> class Abstract(object):
    ...     __metaclass__ = ABCMeta
    ...     @abstractmethod
    ...     def foo(self): pass
    ...     @abstractproperty
    ...     def bar(self): pass
    ... 
    >>> class Concrete(Abstract):
    ...     foo = 'bar'
    ...     bar = 'baz'
    ... 
    >>> Concrete()
    <__main__.Concrete object at 0x104b4df90>
    

    I was able to construct Concrete() even though both foo and bar are simple attributes.

    The ABCMeta metaclass only tracks how many objects are left with the __isabstractmethod__ attribute being true; when creating a class from the metaclass (ABCMeta.__new__ is called) the cls.__abstractmethods__ attribute is then set to a frozenset object with all the names that are still abstract.

    type.__new__ then tests for that frozenset and throws a TypeError if you try to create an instance.

    You'd have to produce your own __new__ method here; subclass ABCMeta and add type checking in a new __new__ method. That method should look for __abstractmethods__ sets on the base classes, find the corresponding objects with the __isabstractmethod__ attribute in the MRO, then does typechecking on the current class attributes.

    This'd mean that you'd throw the exception when defining the class, not an instance, however. For that to work you'd add a __call__ method to your ABCMeta subclass and have that throw the exception based on information gathered by your own __new__ method about what types were wrong; a similar two-stage process as what ABCMeta and type.__new__ do at the moment. Alternatively, update the __abstractmethods__ set on the class to add any names that were implemented but with the wrong type and leave it to type.__new__ to throw the exception.

    The following implementation takes that last tack; add names back to __abstractmethods__ if the implemented type doesn't match (using a mapping):

    from types import FunctionType
    
    class ABCMetaTypeCheck(ABCMeta):
        _typemap = {  # map abstract type to expected implementation type
            abstractproperty: property,
            abstractstatic: staticmethod,
            # abstractmethods return function objects
            FunctionType: FunctionType,
        }
        def __new__(mcls, name, bases, namespace):
            cls = super(ABCMetaTypeCheck, mcls).__new__(mcls, name, bases, namespace)
            wrong_type = set()
            seen = set()
            abstractmethods = cls.__abstractmethods__
            for base in bases:
                for name in getattr(base, "__abstractmethods__", set()):
                    if name in seen or name in abstractmethods:
                        continue  # still abstract or later overridden
                    value = base.__dict__.get(name)  # bypass descriptors
                    if getattr(value, "__isabstractmethod__", False):
                        seen.add(name)
                        expected = mcls._typemap[type(value)]
                        if not isinstance(namespace[name], expected):
                            wrong_type.add(name)
            if wrong_type:
                cls.__abstractmethods__ = abstractmethods | frozenset(wrong_type)
            return cls
    

    With this metaclass you get your expected output:

    >>> class Abstract(object):
    ...     __metaclass__ = ABCMetaTypeCheck
    ...     @abstractmethod
    ...     def foo(self): pass
    ...     @abstractproperty
    ...     def bar(self): pass
    ...     @abstractstatic
    ...     def baz(): pass
    ... 
    >>> class ConcreteWrong(Abstract):
    ...     foo = 'bar'
    ...     bar = 'baz'
    ...     baz = 'spam'
    ... 
    >>> ConcreteWrong()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: Can't instantiate abstract class ConcreteWrong with abstract methods bar, baz, foo
    >>> 
    >>> class ConcreteCorrect(Abstract):
    ...     def foo(self): return 'bar'
    ...     @property
    ...     def bar(self): return 'baz'
    ...     @staticmethod
    ...     def baz(): return  'spam'
    ... 
    >>> ConcreteCorrect()
    <__main__.ConcreteCorrect object at 0x104ce1d10>