python / mypy

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

Regression since mypy 1.7 with functions that return a generic protocol #17191

Open vxgmichel opened 4 months ago

vxgmichel commented 4 months ago

EDIT: I found a simpler example reproducing the regression, see below

The following code used to pass with mypy 1.6.1

from typing import (
    Protocol,
    TypeVar,
    Callable,
    Concatenate,
    ParamSpec,
    reveal_type,
)

X = TypeVar("X")
Y = TypeVar("Y")
P = ParamSpec("P")
I = TypeVar("I", contravariant=True)
O = TypeVar("O", covariant=True)

# A callable converting I to O with parameters P
ConvertorCallable = Callable[Concatenate[I, P], O]

# An object that can convert I to O with parameters P
class ConvertorProtocol(Protocol[I, P, O]):
    def convert(self, __source: I, *args: P.args, **kwargs: P.kwargs) -> O:
        ...

# Decorator to convert a callable to a convertor
def convertor(
    func: ConvertorCallable[X, P, Y],
) -> ConvertorProtocol[X, P, Y]:
    class Convertor:
        def convert(self, source: X, /, *args: P.args, **kwargs: P.kwargs) -> Y:
            return func(source, *args, **kwargs)

    return Convertor()

# A convertor that converts X to a list of X
@convertor
def as_list(source: X, repeat: int = 1) -> list[X]:
    return [source] * repeat

if __name__ == "__main__":
    result = as_list.convert(1, repeat=3)
    reveal_type(result)
    print(result)

However, it no longer passes with mypy 1.7.0 and later:

error: Argument 1 to "convert" of "ConvertorProtocol" has incompatible type "int"; expected Never  [arg-type]

Try it in the playground.

Note that this sample works in the pyright playground.

vxgmichel commented 4 months ago

To add a bit more information, the same example works fine if the I type is included within the P param spec (see that in the playground).

However, being able to extract a type from the param spec as seen in the first code sample is really useful. In particular, the type of the first argument might appear in other methods of the protocol, for instance:

# An object that can convert I to O with parameters P
class ConvertorProtocol(Protocol[I, P, O]):
    def convert(self, __source: I, *args: P.args, **kwargs: P.kwargs) -> O:
        ...

    def prepare(self, *args: P.args, **kwargs: P.kwargs) -> Callable[[I], O]:
        ...
vxgmichel commented 4 months ago

I just came up with a simpler example that can reproduce the regression, without Protocol nor decorators:

from typing import (
    Generic,
    TypeVar,
    Callable,
    Concatenate,
    ParamSpec,
    reveal_type,
)
from dataclasses import dataclass

X = TypeVar("X")
P = ParamSpec("P")
I = TypeVar("I", contravariant=True)
O = TypeVar("O", covariant=True)

@dataclass
class Convertor(Generic[I, P, O]):
    convert: Callable[Concatenate[I, P], O]

def as_list(source: X, repeat: int = 1) -> list[X]:
    return [source] * repeat

as_list_convertor = Convertor(convert=as_list)

if __name__ == "__main__":
    reveal_type(as_list_convertor.convert)
    result = as_list_convertor.convert(1, repeat=3)
    reveal_type(result)
    assert result == [1, 1, 1]

You can see in the playground that it passes with mypy 1.6 but starts failing with mypy 1.7.

randolf-scholz commented 1 month ago

I am having the same issue, mypy infers Never for the type-variable. Interestingly, binding the expression first causes it to infer Any instead. Maybe this could have similar causes as https://github.com/microsoft/pyright/issues/8165?

from typing import Protocol, Sequence, reveal_type, Any

class ClassDecorator[T, **P](Protocol):
    def __call__(self, cls: type[T], /, *args: P.args, **kwargs: P.kwargs) -> type[T]: ...

def as_class_decorator[T, **P](x: ClassDecorator[T, P]) -> ClassDecorator[T, P]:
    return x

def pprint_sequence[S: Sequence](cls: type[S], /, **kwds: Any) -> type[S]:
    return cls

reveal_type(as_class_decorator(pprint_sequence))
# >>> mypy: ClassDecorator[Never, [**kwds: Any]]
# >>> pyright: ClassDecorator[S@pprint_sequence, (**kwds: Any)]

# if we assign to a variable first, we at least don't get `Never`
fn = as_class_decorator(pprint_sequence)
reveal_type(fn)
# >>> mypy: ClassDecorator[Any, [**kwds: Any]]
# >>> pyright: ClassDecorator[S@pprint_sequence, (**kwds: Any)]

[mypy-playground], [pyright playground]