mypy icon indicating copy to clipboard operation
mypy copied to clipboard

Unbound type variables in Callable type alias are substituted with Any

Open Drino opened this issue 1 year ago • 12 comments

Bug Report

A Callable type alias is handled like usual generic, not as Callable. This does not allow to define a type alias for a decorator.

It can be handled via callback Protocol, but in this case it is not possible to support signature transformation via ParamSpec.

To Reproduce

Run mypy on following code:

from typing import Any, Callable, TypeAlias, TypeVar
from typing_extensions import reveal_type

TCallable = TypeVar('TCallable', bound=Callable[..., Any])

TDecorator: TypeAlias = Callable[[TCallable], TCallable]

def factory_with_unbound_type_alias() -> TDecorator:
    ...

def factory_with_bound_type_alias() -> TDecorator[TCallable]:
    ...

def some_function(request: Any) -> str:
    return 'Hello world'


reveal_type(factory_with_unbound_type_alias()(some_function)) 
# note: Revealed type is "Any"
reveal_type(factory_with_bound_type_alias()(some_function))
# note: error: Argument 1 has incompatible type "Callable[[Any], str]"; expected <nothing>
# note: Revealed type is "<nothing>"

Expected Behavior

According to docs:

A parameterized generic alias is treated simply as an original type with the corresponding type variables substituted.

So def factory_with_bound_type_alias() -> TDecorator[TCallable]: should work.

But to be honest this is pretty counterintuitive.

I'd expect:

reveal_type(factory_with_unbound_type_alias()(some_function)) 
# Revealed type is "def (request: Any) -> builtins.str"

Generally speaking I'd expect unbound type variables in Callable type alias to be bound to its call scope, not filled with Any.

It already works this way with Callable itself:

from typing import Any, Callable, Dict, TypeVar
from typing_extensions import reveal_type

T = TypeVar('T')

def callable_factory() -> Callable[[T], T]:
    ...
    
def dict_factory() -> Dict[T, T]:
    ...
    
reveal_type(callable_factory())
# note: Revealed type is "def [T] (T`-1) -> T`-1"
reveal_type(dict_factory())
# note: Revealed type is "builtins.dict[<nothing>, <nothing>]"

I'd expect it to work this way until alias has another non-callable generic depending on this variable (out of this Callable scope), e.g. current behavior in this snippet is fine:

from typing import Any, Callable, List, Union, TypeVar
from typing_extensions import reveal_type

T = TypeVar('T')

TListOrFactory = Union[List[T], Callable[[], List[T]]]

def make_list_or_factory() -> TListOrFactory:
    ...

reveal_type(make_list_or_factory()) 
# note: Revealed type is "Union[builtins.list[Any], def () -> builtins.list[Any]]"

Your Environment

gist mypy-playground

  • Mypy version used: mypy 0.971
  • Python version used: 3.10

Related discussion I've created a similar ticket in Pyright repo: https://github.com/microsoft/pyright/issues/3803

It appears that the right way to handle Callable in Pyright is by passing it a type variable:

def factory_with_bound_type_alias() -> TDecorator[TCallable]:
    ...

reveal_type(factory_with_bound_type_alias()(some_function))
# pyright: Type of "factory_with_bound_type_alias()(some_function)" is "(request: Any) -> str"

I've searched for any discussions on semantics of Callable aliases, but didn't manage to find anything.

So, after all I've created a (dead) discussion in typing repo: https://github.com/python/typing/discussions/1236

Drino avatar Aug 18 '22 17:08 Drino

So, basically these two functions produce different results, but must produce the same thing:

from typing import Any, Callable, TypeAlias, TypeVar
from typing_extensions import reveal_type

TCallable = TypeVar('TCallable', bound=Callable[..., Any])
TDecorator: TypeAlias = Callable[[TCallable], TCallable]

def a() -> TDecorator: ...
def b() -> Callable[[TCallable], TCallable]: ...

reveal_type(a())  # Revealed type is "Any"
reveal_type(b())  # Revealed type is "def [TCallable <: def (*Any, **Any) -> Any] (TCallable`-1) -> TCallable`-1"

I will take a look!

sobolevn avatar Aug 21 '22 08:08 sobolevn

It is not just with Callable, the same problem also affects other cases:

from typing import TypeVar, List
from typing_extensions import reveal_type, TypeAlias

T = TypeVar('T')
TAlias: TypeAlias = List[T]

def a() -> TAlias: ...
def b() -> List[T]: ...

reveal_type(a())  # "builtins.list[Any]"
reveal_type(b())  # "builtins.list[<nothing>]"

sobolevn avatar Aug 21 '22 09:08 sobolevn

One more thing: function b produces an error: Type variable "ex.T" is unbound, while a does not

sobolevn avatar Aug 21 '22 09:08 sobolevn

This is quite hard, at the moment - I have no idea how to do that properly. This is the place I've debug for the most amount of time: https://github.com/python/mypy/blob/2ba64510ad1d4829b420c2bc278990f037e03721/mypy/typeanal.py#L1635-L1663

sobolevn avatar Aug 21 '22 09:08 sobolevn

in basedmypy typevars are allowed in the bound of other typevars.

KotlinIsland avatar Aug 21 '22 12:08 KotlinIsland

@KotlinIsland sorry, I don't understand. Can you please clarify?

sobolevn avatar Aug 21 '22 12:08 sobolevn

Oh, my mistake, this is a TypeVar in an alias, not a TypeVar in the bound of a TypeVar, sorry

KotlinIsland avatar Aug 21 '22 14:08 KotlinIsland

(I believe everyone here knows this but) for what it's worth, I do not consider any of this to be a bug. Just generic type aliases going unapplied (think MyList = List[T]), as Akuli explains: https://github.com/python/typing/discussions/1236

Note that if you use --strict (or --disallow-any-generics), you'll get a nice error message here: error: Missing type parameters for generic type "TDecorator"

hauntsaninja avatar Aug 21 '22 18:08 hauntsaninja

Thanks for the thoughtful discussion! :)

@sobolevn In your example with List you mean it should be <nothing> not Any? I believe in other means this is fine, as List doesn't possess it's own type var scope which can be resolved on call (while Callable does).

@hauntsaninja

I do not consider any of this to be a bug.

The documentation on type alias says:

A parameterized generic alias is treated simply as an original type with the corresponding type variables substituted.

Thus,

def factory_with_bound_type_alias() -> TDecorator[TCallable]:
    ...

supposed to be treated as

def factory_with_bound_type_alias() ->  Callable[[TCallable], TCallable]:
    ...

But they are different.

I believe that either documentation or implementation should be updated.

Apart from this there is an awesome docs section on decorator factories - It's probably worth to put an advice to use Protocol instead of Callable type alias as decorator shortcut (as the latter does not work). Decorator factories look like the case where people attempt to create a shortcut for a generic Callable type and it will be really nice to have some explanation there.

I also wanted to notice that this behavior (mandatory usage of type variable) is quite counterintuitive - from my experience on their first attempt nobody manages to write correct annotation using Callable type alias. Though, it seems that --strict highlight it, so this is probably not an issue.

Drino avatar Aug 22 '22 08:08 Drino

I believe that this is the explanation of this issue which came up with the click 8.1.4 release.

If we define

F = TypeVar("F", bound=Callable[..., Any])
_Decorator: TypeAlias = Callable[[F], F]

Then these two functions are treated differently

def a() -> _Decorator[F]: ...
def b() -> Callable[[F], F]: ...

I don't quite understand how the two are different from mypy's perspective -- mechanically they may have differences, but they look semantically the same to me.

sirosen avatar Jul 07 '23 14:07 sirosen

This isn't really the same as OP's case (which is mostly just use --disallow-any-generics).

I'm pretty sympathetic to your issue, since it's more clearly a break of referential transparency.

The good news is that this is already sort of fixed on master by https://github.com/python/mypy/pull/15287 (the implementation of which I don't yet fully understand), but is currently gated under the --new-type-inference flag.

But to explain mypy's current behaviour, what's happening is that there's a difference in the scope of where the type variable is being bound to. In def b() -> Callable[[F], F]: ..., the type variable is scoped only to the return type (due to a mypy special case that not all type checkers support). Whereas in def a() -> _Decorator[F]: ..., the type variable is scoped to the entire function (so it gets solved to something dumb when the function gets called).

In 3.12, PEP 695 makes this scoping explicit, e.g. def a[F]() -> _Decorator[F]: .... Note that PEP 695 doesn't include a way to scope a type variable to just the return type, but if I had to make up some new syntax that's a combination of PEP 677 + PEP 695, it's the difference between def a[F]() -> (F) -> F and def a() -> [F](F) -> F

The way to spell this that will be clear to all type checkers is unfortunately a little verbose. Use a callback protocol (where the protocol is not generic, but its method is):

class _DecoratorProto(Protocol):
    def __call__(self, __x: F) -> F: ...

Here's a playground link that has more information on why this works and alternatives: https://mypy-play.net/?mypy=latest&python=3.11&gist=061bb59490d083e8e476dce5ba3640aa

hauntsaninja avatar Jul 08 '23 21:07 hauntsaninja

This isn't really the same as OP's case (which is mostly just use --disallow-any-generics).

Ah, thanks for that clarification! It produces the same behavior in which the decorator is determined to take <nothing>, which is how I mixed them up.

I don't intend to open a new issue since I'm not sure it would be productive. There are other issues (https://github.com/python/mypy/issues/11369 ?) which might be the same case.

Thanks for the explanation of what's going on. I'm not sure I understand it, but it sounds like a fix is on its way towards a release. (Presumably --new-type-inference will at some point become default behavior.)

sirosen avatar Jul 10 '23 14:07 sirosen

I don't think --new-type-inference has anything to do with this. There is no bug in mypy here, it is just that the current type syntax doesn't allow to declare type variable scope (unless you explicitly use callback protocols), so mypy must make some assumptions, and sometimes they don't match user intentions. Also this whole issue is just a duplicate of https://github.com/python/mypy/issues/3924

@hauntsaninja I don't think we really need a new syntax. Using the new type alias syntax in PEP 695 should be enough to disambiguate 95% of currently problematic cases:

type GenericDeco[F] = Callable[[F], F]
type PolymorphicDeco = Callable[[F], F]  # note no F type argument on the left

ilevkivskyi avatar Aug 21 '23 15:08 ilevkivskyi

@ilevkivskyi there is some interaction with --new-type-inference on sirosen's case, but it looks like it has changed since I posted my comment.

The difference is from before and after #15754. See:

~/dev/mypy 0d708cb9c λ cat x.py
from typing import Any, Callable, TypeAlias, TypeVar

F = TypeVar("F", bound=Callable[..., Any])
_Decorator: TypeAlias = Callable[[F], F]

def a() -> _Decorator[F]: ...
def b() -> Callable[[F], F]: ...

def f(x: str) -> str: ...
reveal_type(a()(f))
reveal_type(b()(f))

~/dev/mypy 0d708cb9c λ mypy x.py --new-type-inference --disable-error-code empty-body
x.py:10: note: Revealed type is "<nothing>"
x.py:10: error: Argument 1 has incompatible type "Callable[[str], str]"; expected <nothing>  [arg-type]
x.py:11: note: Revealed type is "def (x: builtins.str) -> builtins.str"
Found 1 error in 1 file (checked 1 source file)

# Before #15754 it appears to be fixed by --new-type-inference

~/dev/mypy 0d708cb9c λ gco HEAD~
Previous HEAD position was 0d708cb9c New type inference: complete transitive closure (#15754)
HEAD is now at 2b613e5ba Fix type narrowing of `== None` and `in (None,)` conditions (#15760)

~/dev/mypy 2b613e5ba λ mypy x.py --new-type-inference --disable-error-code empty-body
x.py:10: note: Revealed type is "def (x: builtins.str) -> builtins.str"
x.py:11: note: Revealed type is "def (x: builtins.str) -> builtins.str"
Success: no issues found in 1 source file

~/dev/mypy 2b613e5ba λ mypy x.py --disable-error-code empty-body                     
x.py:10: note: Revealed type is "<nothing>"
x.py:10: error: Argument 1 has incompatible type "Callable[[str], str]"; expected <nothing>  [arg-type]
x.py:11: note: Revealed type is "def (x: builtins.str) -> builtins.str"
Found 1 error in 1 file (checked 1 source file)

re PEP 695: Yeah, I wasn't proposing new syntax, was just trying to explain that referential transparency breaks in sirosen's case because scope is different when inlined, and wanted some way to explain what scope would look like inline

hauntsaninja avatar Aug 21 '23 20:08 hauntsaninja

there is some interaction with --new-type-inference on sirosen's case, but it looks like it has changed since I posted my comment.

Oh wow, I know why it happened. I can actually bring it back, but I think we should not do it this way. If we want to change the default implicit type variable scope in type alias definitions before PEP 695 is widely available (say use some special logic for callable targets), it should be a conscious decision (and should be done during semantic analysis, not as a result of a hack during type checking).

ilevkivskyi avatar Aug 21 '23 21:08 ilevkivskyi

#3924 looks to capture the technical issue succinctly. I think the main problem, as seen from the pallets/click side of things, is both technical and social (and has been largely solved).

The type alias was added as a good faith effort to improve annotations, but it was not obvious that something was broken until it was released. Even for a super-mainstream package like click, there hasn't been a tight enough and well-enough socialized story about how to test annotations for it to have been caught at the time it was added. That is, until recently, when assert_type became part of the stdlib, and it became possible to write

@mydecorator
def foo() -> int: ...

x = foo()
assert_type(x, int)

I have a lingering question which I'll take to #3924, as it seems more appropriate to ask there.

sirosen avatar Aug 21 '23 22:08 sirosen