pythonmetaclasspython-descriptors

Exception thrown in python magic method is lost on the way


For a project, I want to prevent the use of certain methods in an overriding class. As this happens rather frequently, I am using a metaclass to block many methods at once (how and why is not within the scope of this question).

This is done by replacing the methods I want to block by a data descriptor, which throws an exception. Using a data descriptor is helpful, as it comes first in the attribute lookup, but is not of special relevance other than that.

This approach is also used for magic methods which override operators (e.g. the == operator). When calling the magic method __eq__ directly, the exception is raised as expected. When indirectly calling it using ==, it is also raised, but apparently caught somewhere on the way.

MWE:

from typing import override

class ErrorDescriptor:
    def __get__(self, obj: object, objtype: object = None):
        print("Exception incoming")
        raise RuntimeError()

    def __set__(self, obj: object, objtype: object = None): ...

class Meta(type):
    def __new__(cls, name: str, bases: tuple[type, ...], cls_dict: dict[str, object]):
        cls_dict["__eq__"] = ErrorDescriptor()
        return super().__new__(cls, name, bases, cls_dict)

class Foo:
    @override
    def __eq__(self, other: object):
        return True

class Bar(Foo, metaclass=Meta):
    pass

print(Foo() == 0)  # True
print(Bar() == 0)  # Exception incoming\n False
#                                         ^^^^^ ?!
print(Bar().__eq__(0))  # Exception incoming\n raises RuntimeError

Solution

  • The thing to keep in mind is that magic methods, while having an "almost" 1:1 parity with the operator use itself, are not the same: they are called as part of a language protocol on the operator usage. So, there are differences, sometimes not so subtle, between calling the method and using the operator as you found out.

    SO, while at it - although putting the metaclass descriptors as you want for the dunder names partially achieve what you want - Python actually discourages any such use, and these reserved "magic" names should really be well behaved instance methods in the class itself, just doing what is specified in the data model. The section you linked itself metion only methods or None which actually receives a special treatment (it effectively "unsets" the slot)

    I've found over the years that trying to "collide" things with magic names often lead to situations not specified in the documentation have rally undefined behavior (And might change from one version of the language to the other, or be different in different runtimes such as pypy.

    And here goes how it works in cPython: the dunder methods, when using the operators are NOT retrieved via normal name lookup, as it happens with normal names. Rather, the runtime uses the slots reserved for each special magic method directly. So, they do use the descriptor protocol, as you found out Exception Incomming is printed out - but if you tried to write the metaclass __getattribute__ to supply your faulty __eq__, for example, it wouldn't be used.

    The specific behaviour you found is part of the protocol: if a magic method for a certain operator is not found, Python falls back to try the reciprocate operator in the other operand (in the case of +, -, the methods __radd__, __rsub__, etc... in the other object are called.). If you as much as change your descriptor to return a valid callable, and raise the exception inside it, it would work as you expect. (But I don't recommend this approach: note that replacing magic methods by other things than simple method is actually undefined behavior, and it works due to an implementation detail):

    
    
    class ErrorDescriptor:
        def __get__(self, obj: object, objtype: object = None):
            print("Exception incoming")
            def raiser(other):
                raise RuntimeError()
            return raiser
    
        def __set__(self, obj: object, objtype: object = None): ...
    

    A safer approach

    For one, the one-obvious way to do that, since you are already using metaclasses, is to simply raise the error at class creation time, in metaclass' __new__ itself: that will raise earlier than when one tries to make use of one of the methods you want to deny list.

    But, of course, simply checking the incoming namespace would not catch a situation like in your snippet, in that the forbidden override takes place in a superclass. The thing is to check after the class is created.

    And actually, that could be made in __init_subclass__ and you wouldn't even need a metaclass for that. Since you already have the metaclass in place, the code would go like this:

    class Meta(type):
        def __new__(mcls, name: str, bases: tuple[type, ...], cls_dict: dict[str, object]):
            
            cls = super().__new__(mcls, name, bases, cls_dict)
            for meth_name in deny_list:
                if getattr(cls, meth_name, None) is not getattr(object, meth_name, None):
                    raise TypeError(f"Method {meth_name} can't be overriden in class {cls.__name__}")
            return cls
       
    
    

    Or rather, just place this code in __init_subclass__ on your base classes. (And keep in mind the checking code could verify the methods it find are actually defined in the appropriate custon baseclass, if your project requires that, instead of object):

    deny_list = {"__eq__", } #...
    
    class Bar(Foo):
        def __init_subclass__(cls, **kwargs):
            for meth_name in deny_list:
                if getattr(cls, meth_name, None) is not getattr(object, meth_name, None):
                    raise TypeError(f"Method {meth_name} can't be overridden in class {cls.__name__}")
            super().__init_subclass__(**kwargs)