I was about to open a bug report with mypy but then I realised that both mypy and pyright have the same strange behaviour so I thought I would check here first.
In the following both mypy and pyright infer that abs(value) has a different type from value.__abs__():
from typing import Self
class Real:
def __abs__(self) -> Self:
return self
def my_abs[T: Real](value: T, opt: bool) -> T:
reveal_type(abs(value)) # Real
reveal_type(value.__abs__()) # T
return value
This means that return abs(value) is reported as an error by mypy although pyright allows it. I don’t see why the inferred types are different at all though since abs just calls __abs__. The same behaviour is seen with __floor__ and math.floor.
Is this just a bug in both checkers or am I misunderstanding something?
What I was originally trying to do here is to have a protocol for abs but mypy rejects any code that uses it:
from typing import Self, Protocol
class HasAbs(Protocol):
def __abs__(self) -> Self:
return self
def my_abs[T: HasAbs](value: T) -> T:
return abs(value)
# Incompatible return value type
# (got "HasAbs", expected "T")
Note that this is not the same as SupportsAbs[R] because the return type is Self. This is a non-generic protocol for types that are closed under abs.
Ideally it should be possible to write T: SupportsAbs[T] but type checkers don’t allow type constraints to be generic (I run into this limitation frequently).
So, when you call abs(value), type-checkers try to interpret your Real argument as a SupportsAbs[T], which gets resolved to SupportsAbs[Real], so the return type is Real.
On the other hand, if you call value.__abs__(), they use the method defined on the Real class, which is T@my_abs called with an argument of T@my_abs, so the return type is T@my_abs.
The type system currently just lacks the ability to precisely model the runtime behavior of many dunder methods and their associated operators.
In principle, I think we would need some new typing primitives like ReturnType[<function>, <args>] and Member[<type-variable>, <literal-string>] to access members on upper bounded type variables and reference the return type of functions provided their arguments. With such primitives, typeshed could annotate abs() like so, and the two different ways to call it should give the same result.
It is a false positive but I think it is caused by the incorrect inference. It is correct to reject returning HasAbs rather than T and both checkers correctly reject this:
from typing import Self, Protocol
class HasAbs(Protocol):
def __abs__(self) -> Self:
return self
def my_abs[T: HasAbs](value: T, other: HasAbs) -> T:
return other # error
What is incorrect is failing to recognise that abs(value) is of type T. I’m not sure how it is that pyright handles these two cases differently but it reveals the type as HasAbs* with an asterisk rather than HasAbs which presumably means something.
The asterisk is shorthand for something I call a “conditional type”. You can read about it here. You can think of it as an intersection. Pyright doesn’t have general support for intersections (since the concept hasn’t yet been spec’ed), but it does support the limited concept of a concrete type intersected with a type variable. In this case, the type could be written as HasAbs & T, but I chose to display it as HasAbs* because most Python developers are not familiar with intersections. Plus, conditional types differ from intersections in some subtle but important ways — notably, in how value-constrained type variables are handled during function calls.
In this particular example, it could be simplified. In many cases, it cannot. For example, if you use an isinstance pattern to narrow the type to some subtype of HasAbs, it cannot be simplified. Also, if it’s a value-constrained type variable (as opposed to a regular type variable with an upper bound), it cannot be simplified.