python / mypy

Optional static typing for Python
https://www.mypy-lang.org/
Other
18.18k stars 2.77k forks source link

function that accepts sequence of union of literals false positive #12647

Open beauxq opened 2 years ago

beauxq commented 2 years ago

Bug Report

When a function accepts a sequence of union of literals, a tuple of those literals is seen as an error.

To Reproduce

from random import choice
from typing import Literal, Union

a: Union[Literal[1], Literal[2]]
a = choice([1, 2])  # no error as expected
a = choice((1, 2))  # Argument 1 to "choice" has incompatible type "Tuple[int, int]"; expected "Sequence[Union[Literal[1], Literal[2]]]"  [arg-type]

Expected Behavior

no error

Actual Behavior

Argument 1 to "choice" has incompatible type "Tuple[int, int]"; expected "Sequence[Union[Literal[1], Literal[2]]]" [arg-type]

Your Environment

hauntsaninja commented 2 years ago

The error message could potentially be better, but mypy does not have a way of knowing that random.choice will only ever return 1 or 2. The type signature only promises that the return value is an int.

erictraut commented 2 years ago

@hauntsaninja, I would think that mypy could handle this through bidirectional type inference.

For comparison, pyright is able to get it correct. It accepts a = choice([1, 2]) and rejects a = choice([1, 3]).

hauntsaninja commented 2 years ago

Okay, I'll have to think about it more, but how we infer literals is not fully obvious to me.

For example, using something very close to the definition of sum from typeshed (differences are a) ignore the return value of Literal[0] i.e. pretend that sum asserts its input is non-empty, b) use SupportsLenAndGetItem instead of Iterable to further the analogy, c) drop extra overloads):

from typing import TypeVar, Literal, Union, Protocol, Any

_T = TypeVar("_T")
_T_co = TypeVar("_T_co", covariant=True)

class SupportsLenAndGetItem(Protocol[_T_co]):
    def __len__(self) -> int: ...
    def __getitem__(self, __k: int) -> _T_co: ...

class _SupportsSum(Protocol):
    def __add__(self, __x: Any) -> Any: ...

_SumT = TypeVar("_SumT", bound=_SupportsSum)

def choice(seq: SupportsLenAndGetItem[_T]) -> _T: ...

def sum(seq: SupportsLenAndGetItem[_SumT]) -> _SumT: ...

b: Union[Literal[1], Literal[2]]
b = sum((1, 2))  # pyright doesn't complain, but b is now 3
JelleZijlstra commented 2 years ago

Yes, inferring literal types for typevars is tricky. But in the OP's example, we accept the choice([1, 2]) case and reject the choice((1, 2)) case, and I don't think that makes sense. Either we accept both (using inference rules similar to pyright's) or we reject both (to avoid binding typevars to literals).

hauntsaninja commented 2 years ago

Oh oops, I missed that part of OP's question. Yes, mypy should definitely at least be self-consistent (probably one of few times where list's invariance results in fewer errors :-) ).

beauxq commented 2 years ago

The tuple should be safer than the list, since tuple is immutable.

In this example, the choice for the list should give an error, but the choice on the tuple should not.

from typing import List, Literal, Tuple, Union
from random import choice

lists: List[List[int]] = []
tuples: List[Tuple[int, ...]] = []

def sneak_in_3() -> None:
    lists[0][0] = 3
    # tuples[0][0] = 3  # can't sneak 3 into tuple

def main() -> None:
    x = [1, 2]
    y = (1, 2)

    lists.append(x)
    tuples.append(y)

    sneak_in_3()

    a: Union[Literal[1], Literal[2]]
    a = choice(x)
    a = choice(y)

    print(a)