Make type hints for `__eq__` of primitives less strict

Reviving an old topic from GH 8217

Basically many packages re-define __eq__ to return custom classes, including numpy and pandas.
This conflicts with the typeshed definition that should return bool.
But since the typeshed definition accepts object types any operation like

[1, 2, 3] == np.array([1, 2, 3])

will be inferred to bool when in actuality it is a np.ndarray.
Additionally, there is no way to overwrite this using ignores.

1 Like

Per the issue, we should try returning NotImplemented in cases where the comparison is not explicitly supported. For example,

class list(...):
    @overload
    def __eq__(self, other: listOrWhatever) -> bool: ...
    @overload
    def __eq__(self, other: object) -> NotImplemented: ...

An exploratory PR to just the effect of such a change would be very welcome. We could try to express object.__eq__() like this:

class object:
    @overload
    def __eq__(self, other: Self) -> Literal[True]: ...
    @overload
    def __eq__(self, other: object) -> NotImplemented: ...

But that would mean # type: ignore in all sub classes that implement __eq__. I think it’s safer to use:

class object:
    @overload
    def __eq__(self, other: Self) -> Literal[True]: ...
    @overload
    def __eq__(self, other: object) -> NotImplemented | bool: ...
1 Like

Could you swap the operand order by writing np.array([1, 2, 3]) == [1, 2, 3]? I speculate then you could define an __eq__ on np.array that returns something other than bool.

That will be a an invalid override of object.__eq__ which currently returns bool.

That’s what numpy and pandas are already doing (and ignoring the invalid override).

And just to cite your documentation https://docs.python.org/3/library/operator.html:

Note that these functions can return any value, which may or may not be interpretable as a Boolean value.

1 Like

I don’t think the first overload is accurate. With these overloads, a type checker would pick the first overload to infer the type here, leading to an inferred type of Literal[True]. But at runtime, z is False.

x = object()
y = object()
z = x == y
reveal_type(z)

changing the base typing to:

class HasBool(Protocol):
    def __bool__(self) -> bool:
        ...

class object:
    def __eq__(self, other: object) -> HasBool:
        ...

Would remain accurate to how the implicit calls to bool may happen, while allowing subclasses so long as the non-bool object they return on == provides an __eq__ implementation, even if that implementation only ever errors to inform people they are missing something intended for use in alternative ways (pandas, sqla, etc)

So there are two issues here, one about the return type and one about the “other” type.

The return type problem is less severe because one can type ignore custom solutions.

But it would be nice if list etc. would not lie about which types they can operate on.
I think the confusion here is that while it is true that it can accept any argument at runtime, from a typing perspective it would be enough to say e.g. `def eq(self, other: Self) → bool and type checkers will know what to do. I don’t think the extra overloads are necessary.

I don’t agree with this implementation. This implementation still assumes that __eq__ to return a truthy value, which in return results in wrong type inference in the type checking phase.

Just to be clear about what the original issue is I’m gonna paste it here:

As can be seen in builtins.pyi, all primitives are defining their __eq__ and __ne__ functions in the following manner:

   def __eq__(self, __x: object) -> bool: ...
   def __ne__(self, __x: object) -> bool: ...

This will cause incorrect type errors in cases where the second operand has overloaded __eq__ or __ne__ in a way that it does not return bool. Consider the following example:

T = TypeVar("T", float, bool)

class Matrix(Generic[T]):
  def __init__(self, data: List[List[T]]):
    self.data = data

  def __eq__(self, other: "Matrix[T] | float | bool") -> Matrix[bool]:
    if not isinstance(other, Matrix):
      return Matrix([[elem == other for elem in row] for row in self.data])
    return Matrix((elem == other_elem for elem, other_elem in zip(row1, row2)] for row1, row2 in zip(self.data, other.data)]

  def __ne__(self, other: "Matrix[T] | float | bool") -> Matrix[bool]: ...

Here we’re trying to create a class that mimics numpy’s NDArray in a very limited way.

Now if I try to check the equality of a Matrix with a float, the return type will be inferred as Matrix, since LHS of the operation has a matching __eq__ method which returns matrix (and the returned type in runtime is Matrix as well)

But if I try to check the equality of a float with a Matrix, the returned type will be inferred as bool because LHS of the operation has a compatible __eq__ (def __eq__(self, __x: object) -> bool, bject and Matrix ar compatible) while in the runtime because float’s eq will return a NotImplemented, interpreter will try to run the __ne__ function of the RHS which returns a Matrix.

This will create situations where the inferred type of a simple equality check operation does not match the runtime’s type, all because primitives stubs has been defined in a way that is not representative of their runtime behavior.

As any class that returns a non-boolean in its __eq__ is effectively breaking the Liskov substitution principle, I don’t think we’ll be able to create a consistent type for __eq__ without forcing object’s subclasses to use a # type: ignore due to incompatible overwrite.

Solution:

This is my suggestion regarding the __eq__'s type hint in order for the hint to correctly reflect CPython’s implementation of the equality check process:

For object:

class object:
    def __eq__(self, other: object) -> NotImplemented: ...

For other builtin classes[1], look at the CPython’s implementation of __eq__ to determine their appropriate type hints. For example, the float’s and complex’s __eq__ will be defined as:

class float:
    def __eq__(self, other: int | float) -> bool: ...

class complex:
    def __eq__(self,  other: int | float | complex) -> bool: ...

That’s because based on CPython’s code float’s __eq__ will return an UnimplementedError if the other operand is not an int or float, while complex’s __eq__ will return an UnimplementedError if the other operand is not an int, float, or complex. Take a look at float_richcompare and complex_richcompare functions in CPython’s source code.

Rational

I understand that this will require almost all __eq__ overwrites to have a # type: ignore comment due to them being incompatible with the object’s one, but the reality of the matter is that the way that Python’s equality check works forces us to do so if we want to have type hints that reflects the runtime’s behavior.

Also I understand that defining the object’s __eq__ in a way that always returns a NotImplemented is controversial, but that’s how the CPython’s interpreter is working. It’s a little hard to explain my rational for this, so bare with me.

First, Keep in mind that when python encounters == it first calls the LHS’s __eq__, if it does not exist or returns a NotImplemented, then it calls __eq__ of the RHS. If it does not exist or returns a NotImplemented then it compare’s the object pointers (i.e: if the LHS and RHS are same object in the momory it returns True, otherwise False).

Now take a look at following code:

class A:
   def __eq__(self, other):
      print("A")
      return NotImplemented

class B:
   def __eq__(self, other):
      print("B")
      return NotImplemented

class C:
   def __eq__(self, other):
      print("C")
      return True

object() == object()    # (1) Evaluates to False, as their distinct objects
A() == B()              # (2) Prints 'A' then 'B' then evaluates to False, as their distinct objects
B() == A()              # (3) Prints 'B' then 'A' then evaluates to False, as their distinct objects
object() == A()         # (4) Prints 'A' then evaluates to False, as their distinct objects
A() == object()         # (5) Prints 'A' then evaluates to False, as their distinct objects
object() == C()         # (6) Prints 'C' then evaluates to True, as C's __eq__ always return True
C() == object()         # (7) Prints 'C' then evaluates to True, as C's __eq__ always return True
A() == C()              # (8) Prints 'A' then 'C' then evaluates to True, as C's __eq__ always return True
C() == A()              # (9) Prints 'C' then evaluates to True, as C's __eq__ always return True

These results tells me that object() does not have an __eq__ implementation (look at (4) & (5)) and unless the other value defines a valid __eq__, the equality check will fall back to the object pointer comparison (look at (6) & (7)).

Again, I know this proposal in controversial, but I think it’s the only way to type hint __eq__ in a way the lets type checkers infer the correct result of a comparison when comparing a builtin object with a custom object with a non-standard __eq__ overwrite.


  1. The builtin classes that typeshed provides type hints for are: int, float, complex, str, bytes, bytearray, memoryview, slice, tuple, list, dict, set, frozenset, and range. ↩︎

2 Likes

Sorry about my last post. I’ve missed that it’s the argument type causes the need of # type: ignore.

Here’s my 2c –

For object, __eq__ is removed as if it doesn’t exist. For other classes, __eq__ is declared only for supported RHS types.

class object:
    # No `__eq__` method.
    ...

class float:
    def __eq__(self, other: int | float) -> bool: ...

class complex:
    def __eq__(self, other: int | float | complex) -> bool: ...

For expression like a == b, a type checker should first attempt evaluating a.__eq__(b), then b.__eq__(a) if first attempt failed, and finally decide that the result should be Literal[False] if both previous attempts failed.