Mypy doesn't seem to use types from @overload-ed __sub__
Bug Report
The __sub__ method on the Arrow class from the arrow package has several @overloads:
@overload
def __sub__(self, other: Union[timedelta, relativedelta]) -> "Arrow":
pass # pragma: no cover
@overload
def __sub__(self, other: Union[dt_datetime, "Arrow"]) -> timedelta:
pass # pragma: no cover
def __sub__(self, other: Any) -> Union[timedelta, "Arrow"]:
if isinstance(other, (timedelta, relativedelta)):
return self.fromdatetime(self._datetime - other, self._datetime.tzinfo)
elif isinstance(other, dt_datetime):
return self._datetime - other
elif isinstance(other, Arrow):
return self._datetime - other._datetime
return NotImplemented
In particular, one Arrow object minus another Arrow object will always yield a timedelta. However, it seems mypy does not see this, and mistakes the type for Arrow.
To Reproduce
I discovered the issue when finding the time diff between two arrow timestamps:
import arrow
a1 = arrow.get()
a2 = arrow.get()
diff = a2 - a1
print(diff.total_seconds())
Which, when run with mypy yields:
$ mypy test.py
test.py:6: error: "int" not callable
Found 1 error in 1 file (checked 1 source file)
Setting the type explicitly reviles that mypy thinks the type is Arrow, and not timedelta:
from datetime import timedelta
import arrow
a1 = arrow.get()
a2 = arrow.get()
diff: timedelta = a2 - a1
print(diff.total_seconds())
Running mypy:
$ mypy test.py
test.py:7: error: Incompatible types in assignment (expression has type "Arrow", variable has type "timedelta")
Found 1 error in 1 file (checked 1 source file)
Expected Behavior
The second @overload on Arrow's __sub__ method specifies that the diff of two Arrow objects is a timedelta. There should be no type error assigning that to a timedelta variable.
Actual Behavior
Mypy mistakenly thinks the diff is Arrow, which creates a false positive.
Your Environment
- Mypy version used: 0.910
- Mypy command-line flags: None
- Mypy configuration options from
mypy.ini(and other config files): None - Python version used: 3.9.6
- Operating system and version: Mac OS 11.6 (Big Sur)
@mathialo looks like this example works for me on master:
from typing import overload
class Arrow:
@overload
def __sub__(self, other: int) -> int:
...
@overload
def __sub__(self, other: str) -> str:
...
def __sub__(self, other: int | str) -> int | str:
...
a: Arrow
reveal_type(a - 1) # N: Revealed type is "builtins.int"
reveal_type(a - 'a') # N: Revealed type is "builtins.str"
And this one as well:
from typing import overload, Union, Any
from datetime import timedelta, datetime as dt_datetime
class relativedelta:
pass
class Arrow:
@overload
def __sub__(self, other: Union[timedelta, relativedelta]) -> "Arrow":
pass # pragma: no cover
@overload
def __sub__(self, other: Union[dt_datetime, "Arrow"]) -> timedelta:
pass # pragma: no cover
def __sub__(self, other: Any) -> Union[timedelta, "Arrow"]:
pass
a: Arrow
t: timedelta
r: relativedelta
d: dt_datetime
reveal_type(a - t) # N: Revealed type is "ex.Arrow"
reveal_type(a - r) # N: Revealed type is "ex.Arrow"
reveal_type(a - d) # N: Revealed type is "datetime.timedelta"
reveal_type(a - a) # N: Revealed type is "datetime.timedelta"
Can you please verify?
Hello, I'm experiencing this issue as well. I installed the master branch and your simple example work correctly there, but it still is incorrect for the Arrow library. I checked, and in the most recent release (0.942) you get the same behavior, the simple example works correctly but it does not for Arrow.
I noticed that if you switch the order of the __sub__ overloads in arrow.py such that the -> timedelta overload occurs first, then the example from @mathialo works correctly. Is the order in which overloads get specified important? Is there something about arrow's __sub__ implementation that makes mypy unable to correctly infer the type?
Could you provide a minimal example that reproduces the issue? Possible explanations I can see:
- There is an
__rsub__with an Any argument type, does that make a diifference? - Perhaps one of the argument types resolves to Any, causing a particular overload to always match.
And yes, overload order does matter; mypy generally picks the first matching overload.
In trying to make a minimal example, I believe I figured out the issue. The issue is that relativedelta in Arrow is provided by the third-party dateutil library, which doesn't ship with types. I believe because of this, relativedelta was being interpreted as Any, which means that the first overload is always matched, giving us Arrow no matter what.
The easy fix is the install pip install types-python-dateutil, which gives the typing information resulting in the correct overloading behavior regardless of order. Any downstream project using Arrow can add this to their requirements as well to resolve this issue. I will also open a PR on Arrow to have this done by default.
In any case, this doesn't appear to be a Mypy issue, so this issue is likely not indicative of a bug. However, it could be a nice Mypy feature to surface this kind of issue through a warning. Perhaps a disable-able warning if any argument to an overload is Any and there is a more specific type for that argument later in the list of overloads?
Thanks for figuring that out!
I explored a few simpler examples:
from typing import Any, overload, Union
class X:
@overload
def __sub__(self, x: Any) -> int:
pass
@overload
def __sub__(self, x: int) -> str:
pass
def __sub__(self, x: object) -> Any:
raise NotImplementedError
produces main.py:9: error: Overloaded function signature 2 will never be matched: signature 1's parameter type(s) are the same or broader (great!)
But now we change the first overload to Union[Any, str]:
from typing import Any, overload, Union
class X:
@overload
def __sub__(self, x: Union[str, Any]) -> int:
pass
@overload
def __sub__(self, x: int) -> str:
pass
def __sub__(self, x: object) -> Any:
raise NotImplementedError
And we get main.py:5: error: Overloaded function signatures 1 and 2 overlap with incompatible return types. That's true, but I'd rather see the previous error.
Perhaps we should open a new issue focused on improving this behavior.
FYI, as of https://github.com/python/mypy/pull/17392, the second minimal example no longer shows an error. (due the Any overlap changes, I think)