Setup automatic inheritance of parent class' subclass in Python

Question:

When running this code, the print displays the message "no number found in class A", although in fact it was not found in an object of class B.
The aim is to change only the Base class in such a way that, when inheriting from it, descendants create their own NotFoundError exception inherited from Base.

class Base:
    class NotFoundError(Exception):
        pass

    def __init__(self, numbers: list[int]):
        self.numbers = numbers

    def pop_by_val(self, number: int):
        try:
            self.numbers.remove(number)
        except ValueError:
            raise self.NotFoundError()


class A(Base):
    pass


class B(Base):
    pass


a = A([1, 2])
b = B([1, 2])

try:
    a.pop_by_val(1)
    b.pop_by_val(3)
except A.NotFoundError:
    print("no number found in class A")
except B.NotFoundError:
    print("no number found in class B")

I guess it can be fixed by some kind of init/new dunders’ customization, but I haven’t succeeded in my tries

Answers:

A.NotFoundError and B.NotFoundError point to the same class object, as can be verified by running:

print(id(A.NotFoundError) == id(B.NotFoundError))

When you run

try:
    a.pop_by_val(1)
    b.pop_by_val(3)
except A.NotFoundError:
    print("no number found in class A")
except B.NotFoundError:
    print("no number found in class B")

The first pop_by_val(1) succeeds, so the program goes on. On the second b.pop_by_val(3), B.NotFoundError is raised, but since B.NotFoundError is identical to A.NotFoundError, the exception is caught by the first except clause and therefore prints no number found in class A.

Answered By: Fractalism

One solution to this is to write a metaclass that creates a new exception type for each subclass.

class NotFoundError(Exception):
     pass

class NotFoundErrorMeta(type):

    def __init__(cls, name, bases, attrs):
        super().__init__(name, bases, attrs)
        cls.NotFoundError = type("NotFoundError", (NotFoundError,), {
            "__qualname__": cls.__qualname__ + ".NotFoundError" })

class Base(metaclass=NotFoundErrorMeta):

    def __init__(self, numbers: list[int]):
        self.numbers = numbers

    def pop_by_val(self, number: int):
        try:
            self.numbers.remove(number)
        except ValueError:
            raise self.NotFoundError()

Now each class raises its own NotFoundError exception. These are subclasses of the NotFoundError class defined outside the metaclass, so you can catch any NotFoundError via that base class, or catch a specific class’s exception.

Answered By: kindall

There’s no automatic definition of new, inherited class attributes, but you can define __init_subclass__ to define them for you.

class Base:
    class NotFoundError(Exception):
        pass

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        class NotFoundError(Base.NotFoundError):
            pass
        cls.NotFoundError = NotFoundError

    def __init__(self, numbers: list[int]):
        self.numbers = numbers

    def pop_by_val(self, number: int):
        try:
            self.numbers.remove(number)
        except ValueError:
            raise self.NotFoundError()

(This is basically doing the same thing as @kindall’s answer, but avoids using a metaclass. Prefer __init_subclass__ where possible to make it easier for your class to interact with more classes, as metaclasses don’t compose well.)

Answered By: chepner

It seems that you expect A.NotFoundError and B.NotFoundError to be separate classes, since A and B are separate classes. However, that is not the case.

The code inside class Base: is not some kind of template that is re-run for each derived class. There is an actual object that represents that class, and the code creates that object, and that object has another class, Base.NotFoundError, as an attribute.

The derived classes don’t define attributes named NotFoundError themselves; thus, looking up NotFoundError within them finds the one attached to the Basejust like how e.g. A.__init__ implicitly calls Base.__init__.

It doesn’t really make sense to use subclassing for this example – instead, just use separate instances that each have their own .numbers; the explicitly raised exception could include a reference to self if necessary, making it clear which instance failed to contain the number.

However, sometimes there will be a legitimate use case for making a "parallel" structure of classes like this, wherein each derived class has its own associated class. Of course it works to just define NotFoundError within each derived class, but there are ways to minimize the boilerplate.

In particular, we can use a metaclass (which actually can be implemented as a function), or else a class decorator.


Function-as-metaclass approach

A metaclass is a thing used to create class instances. It’s simply the class, of which those classes are instances. By default, every class is an instance of the built-in type. We can define our own metaclass that does something similar to what type does at instantiation time, but also adds the corresponding exception class. Thus:

def TypeWithNotFound(name, bases, attributes):
    attributes['NotFoundError'] = type('NotFoundError', (Exception,), {})
    return type(name, bases, attributes)

class Base(metaclass=TypeWithNotFound):
    def __init__(self, numbers):
        self.numbers = numbers
    def pop_by_val(self, number):
        try:
            self.numbers.remove(number)
        except ValueError:
            raise self.NotFoundError()

class A(Base, metaclass=TypeWithNotFound): pass

class B(Base, metaclass=TypeWithNotFound): pass

For the metaclass, we can use a function rather than a class, since we only need to replace what happens when it’s called (our classes will actually still have type as a metaclass, just the setup process is modified). We are passed the name of the new class as a string, a tuple of base classes, and a dict of attributes. (Python normally creates classes by computing these arguments from the class body, and then calling type with them.) So, we dynamically create an exception class, store it in the attributes dict, and then dynamically create the actual class.

From there, we don’t need any code within each class to create corresponding exceptions – but we do need to use the metaclass for all of them.

Class decorator approach

This is arguably simpler. We simply define a function that accepts, modifies and returns a class, and then we can use that function as a decorator for classes. Specifically, we will modify the class by creating and adding a related exception type. Thus:

def addNotFound(cls):
    cls.NotFoundError = type('NotFoundError', (Exception,), {})
    return cls

@addNotFound
class Base:
    # as before

@addNotFound
class A(Base): pass

@addNotFound
class B(Base): pass

Actual metaclass approach

This is a little harder conceptually, but it minimizes the boilerplate even more: derived classes will necessarily have the same metaclass as their base.

class TypeWithNotFound(type):
    def __new__(cls, name, bases, attributes):
        attributes['NotFoundError'] = type('NotFoundError', (Exception,), {})
        return super().__new__(cls, name, bases, attributes)

We defined a subclass of the type metaclass, and defined its __new__. Now, when instances of TypeWithNotFound (i.e.: classes that have this metaclass) are created, the overridden __new__ will modify the attributes dict before delegating back to the base type.__new__. (Since we are overriding __new__ rather than __init__, the first parameter will be the class TypeWithNotFound itself, which needs to be forwarded to the base __new__ call. (This is just standard best practice, in case we later need to implement cooperative multiple inheritance between metaclasses.)

Anyway, now we can simply define Base to use this metaclass, and the derived classes will also use it automatically:

class Base(metaclass=TypeWithNotFound):
    # as before

class A(Base): pass

class B(Base): pass

For each of these approaches, the reader should be able to verify the result:

>>> a = A([1, 2])
>>> b = B([1, 2])
>>> 
>>> try:
...     a.pop_by_val(1)
...     b.pop_by_val(3)
... except A.NotFoundError:
...     print("no number found in class A")
... except B.NotFoundError:
...     print("no number found in class B")
... 
no number found in class B

References / See Also

What are metaclasses in Python?

How to decorate a class?

Answered By: Karl Knechtel
Categories: questions Tags: , , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.