Search code examples
pythonexceptionabstract-classclass-properties

How to create an abstract class attribute (potentially read-only)


I have spent a lot of time researching this, but none of the answers seem to work how I would like.

I have an abstract class with a class attribute I want each subclass to be forced to implement

class AbstractFoo():
    forceThis = 0

So that when I do this

class RealFoo(AbstractFoo):
    pass

it throws an error telling me it can't create the class until I implement forceThis.

How can I do that?

(I don't want the attribute to be read-only, but if that's the only solution, I'll accept it.)

For a class method, I've discovered I can do

from abc import ABCMeta, abstractmethod

class AbstractFoo(metaclass=ABCMeta):
    @classmethod
    @abstractmethod
    def forceThis():
        """This must be implemented"""

so that

class RealFoo(AbstractFoo):
    pass

at least throws the error TypeError: Can't instantiate abstract class EZ with abstract methods forceThis

(Although it doesn't force forceThis to be a class method.)

How can I get a similar error to pop up for the class attribute?


Solution

  • I came up with a solution based on those posted earlier. (Thank you @Daniel Roseman and @martineau)

    I created a metaclass called ABCAMeta (the last 'A' stands for 'Attributes').

    The class has two ways of working.

    1. A class which just uses ABCAMeta as a metaclass must have a property called required_attributes which should contain a list of the names of all the attributes you want to require on future subclasses of that class

    2. A class whose parent's metaclass is ABCAMeta must have all the required attributes specified by its parent class(es).

    For example:

    class AbstractFoo(metaclass=ABCAMeta):
        required_attributes = ['force_this']
    
    class RealFoo(AbstractFoo):
        pass
    

    will throw an error:

    NameError: Class 'RealFoo' has not implemented the following attributes: 'force_this'

    Exactly how I wanted.

    from abc import ABCMeta
    
    class NoRequirements(RuntimeError):
            def __init__(self, message):
                RuntimeError.__init__(self, message)
    
    class ABCAMeta(ABCMeta):
        def __init__(mcls, name, bases, namespace):
            ABCMeta.__init__(mcls, name, bases, namespace)
    
        def __new__(mcls, name, bases, namespace):
            def get_requirements(c):
                """c is a class that should have a 'required_attributes' attribute
                this function will get that list of required attributes or
                raise a NoRequirements error if it doesn't find one.
                """
    
                if hasattr(c, 'required_attributes'):
                    return c.required_attributes
                else:
                    raise NoRequirements(f"Class '{c.__name__}' has no 'required_attributes' property")
    
            cls = super().__new__(mcls, name, bases, namespace)
            # true if no parents of the class being created have ABCAMeta as their metaclass
            basic_metaclass = True
            # list of attributes the class being created must implement
            # should stay empty if basic_metaclass stays True
            reqs = []
            for parent in bases:
                parent_meta = type(parent)
                if parent_meta==ABCAMeta:
                    # the class being created has a parent whose metaclass is ABCAMeta
                    # the class being created must contain the requirements of the parent class
                    basic_metaclass=False
                    try:
                        reqs.extend(get_requirements(parent))
                    except NoRequirements:
                        raise
            # will force subclasses of the created class to define
            # the attributes listed in the required_attributes attribute of the created class
            if basic_metaclass:
                get_requirements(cls) # just want it to raise an error if it doesn't have the attributes
            else:
                missingreqs = []
                for req in reqs:
                    if not hasattr(cls, req):
                        missingreqs.append(req)
                if len(missingreqs)!=0:
                    raise NameError(f"Class '{cls.__name__}' has not implemented the following attributes: {str(missingreqs)[1:-1]}")
            return cls
    

    Any suggestions for improvement are welcome in the comments.