2025, Nov 19 09:00
Operators vs magic methods in Python: why your descriptor exception vanishes and safer ways to block overrides
Learn why Python operators bypass descriptor traps on magic methods, and how to make both paths raise. Safer: block overrides at class creation via metaclasses.
Blocking certain methods in subclasses sounds simple until magic methods enter the room. A common approach is to use a metaclass that replaces forbidden methods with data descriptors that raise, so any access fails fast. This works fine when you call the method directly. But when the same method is triggered by an operator, Python’s data model kicks in and your exception may appear to vanish. Let’s unpack what’s really happening and how to get consistent behavior.
Problem demonstration
from typing import override
class TrapAttr:
def __get__(self, inst: object, owner: object = None):
print("Exception incoming")
raise RuntimeError()
def __set__(self, inst: object, owner: object = None): ...
class Hook(type):
def __new__(mcls, cls_name: str, parents: tuple[type, ...], ns: dict[str, object]):
ns["__eq__"] = TrapAttr()
return super().__new__(mcls, cls_name, parents, ns)
class BaseThing:
@override
def __eq__(self, rhs: object):
return True
class DerivedThing(BaseThing, metaclass=Hook):
pass
print(BaseThing() == 0) # True
print(DerivedThing() == 0) # Exception incoming
# False
print(DerivedThing().__eq__(0)) # Exception incoming
# raises RuntimeError
Directly invoking the special method raises as expected. Using the operator prints the message from the descriptor, but the expression returns False instead of propagating the exception.
What’s going on under the hood
Special methods are part of a language protocol. Even though many of them map “almost” one-to-one to operators, Python does not resolve them via ordinary attribute lookup when an operator is used. Instead, the runtime consults slots reserved for those methods and follows a protocol that includes fallbacks. In the equality case, if the left-hand side’s special method resolution does not yield a usable implementation, Python tries the reflected operation on the right-hand operand.
In the example, accessing the descriptor prints the message and raises. When the operator drives the process, the protocol continues as if an implementation were not provided and attempts the reflected operation, which results in False. That is why the exception appears to be swallowed on the operator path, but not when the method is called directly.
There is another important angle: replacing magic methods with non-method objects is discouraged. These names are reserved and are expected to be well-behaved instance methods. Straying from that contract easily leads to behavior that is not guaranteed across implementations or versions.
A minimal change to make both paths raise
If you still want the operator and the direct call to behave the same, make the descriptor return a callable and raise inside that callable. This way, the operator protocol finds something callable in the slot and actually executes it, at which point your exception is raised consistently.
class TrapAttr:
def __get__(self, inst: object, owner: object = None):
print("Exception incoming")
def boom(other):
raise RuntimeError()
return boom
def __set__(self, inst: object, owner: object = None): ...
This aligns both code paths without changing the external behavior of the protocol, though the caveat still applies: replacing special-method names with anything other than straightforward methods is not a recommended pattern.
A safer approach: reject at class creation
Instead of intercepting usage at runtime, stop the class from being created if it tries to override forbidden methods. Since you already have a metaclass, perform the check after the class object is created and validate the effective MRO result.
blocked_set = {"__eq__"}
class TypeGate(type):
def __new__(mcls, cls_name: str, parents: tuple[type, ...], ns: dict[str, object]):
cls = super().__new__(mcls, cls_name, parents, ns)
for meth in blocked_set:
if getattr(cls, meth, None) is not getattr(object, meth, None):
raise TypeError(f"Method {meth} can't be overriden in class {cls.__name__}")
return cls
You can achieve the same without a metaclass by putting the check in __init_subclass__ of a base class. The logic is identical; it runs after subclass creation and rejects disallowed overrides.
blocked_set = {"__eq__"}
class GuardBase:
def __init_subclass__(cls, **kwargs):
for meth in blocked_set:
if getattr(cls, meth, None) is not getattr(object, meth, None):
raise TypeError(f"Method {meth} can't be overridden in class {cls.__name__}")
super().__init_subclass__(**kwargs)
Why this matters
Special methods participate in a protocol with fallbacks. Assuming they behave like ordinary attributes leads to surprising behavior, especially around reflected operators. Relying on nonstandard descriptors for magic names can easily step into undefined territory, while rejecting forbidden overrides at definition time gives deterministic, early failure.
Conclusion
If you need consistent failure for operator and direct-call paths, return a callable from the descriptor and raise inside it. If you want robust control over what subclasses can override, validate at class creation, either in a metaclass after instantiation or in __init_subclass__ on a base class. Above all, remember that magic methods aren’t regular attributes during operator execution; they’re governed by the data model protocol with its own resolution and fallback rules.