Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unbound type variables in Callable type alias are substituted with Any #13449

Closed
Drino opened this issue Aug 18, 2022 · 16 comments
Closed

Unbound type variables in Callable type alias are substituted with Any #13449

Drino opened this issue Aug 18, 2022 · 16 comments
Assignees
Labels
bug mypy got something wrong topic-type-alias TypeAlias and other type alias issues

Comments

@Drino
Copy link

Drino commented Aug 18, 2022

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:
microsoft/pyright#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:
python/typing#1236

@Drino Drino added the bug mypy got something wrong label Aug 18, 2022
@Drino Drino changed the title Callable type alias unbound type variables are substituted with Any Unbound type variables in Callable type alias are substituted with Any Aug 18, 2022
@AlexWaygood AlexWaygood added the topic-type-alias TypeAlias and other type alias issues label Aug 18, 2022
@sobolevn
Copy link
Member

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 sobolevn self-assigned this Aug 21, 2022
@sobolevn
Copy link
Member

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
Copy link
Member

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

@sobolevn
Copy link
Member

sobolevn commented Aug 21, 2022

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:

mypy/mypy/typeanal.py

Lines 1635 to 1663 in 2ba6451

def visit_unbound_type(self, t: UnboundType) -> TypeVarLikeList:
name = t.name
node = None
# Special case P.args and P.kwargs for ParamSpecs only.
if name.endswith("args"):
if name.endswith(".args") or name.endswith(".kwargs"):
base = ".".join(name.split(".")[:-1])
n = self.lookup(base, t)
if n is not None and isinstance(n.node, ParamSpecExpr):
node = n
name = base
if node is None:
node = self.lookup(name, t)
if (
node
and isinstance(node.node, TypeVarLikeExpr)
and (self.include_bound_tvars or self.scope.get_binding(node) is None)
):
assert isinstance(node.node, TypeVarLikeExpr)
return [(name, node.node)]
elif not self.include_callables and self._seems_like_callable(t):
return []
elif node and node.fullname in LITERAL_TYPE_NAMES:
return []
elif node and node.fullname in ANNOTATED_TYPE_NAMES and t.args:
# Don't query the second argument to Annotated for TypeVars
return self.query_types([t.args[0]])
else:
return super().visit_unbound_type(t)

@KotlinIsland
Copy link
Contributor

KotlinIsland commented Aug 21, 2022

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

@sobolevn
Copy link
Member

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

@KotlinIsland
Copy link
Contributor

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

@hauntsaninja
Copy link
Collaborator

hauntsaninja commented Aug 21, 2022

(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: python/typing#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"

@Drino
Copy link
Author

Drino commented Aug 22, 2022

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.

@sirosen
Copy link

sirosen commented Jul 7, 2023

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.

@hauntsaninja
Copy link
Collaborator

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 #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

@sirosen
Copy link

sirosen commented Jul 10, 2023

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 (#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.)

wence- added a commit to wence-/distributed that referenced this issue Jul 11, 2023
Since click 8.1.4, mypy fails to deduce the correct types in the click
type annotations for click.command. See pallets/click#2558 and
python/mypy#13449.

For now, workaround by ignoring the arg-type error.
@ilevkivskyi
Copy link
Member

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 #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 ilevkivskyi closed this as not planned Won't fix, can't repro, duplicate, stale Aug 21, 2023
@hauntsaninja
Copy link
Collaborator

@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

@ilevkivskyi
Copy link
Member

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).

@sirosen
Copy link

sirosen commented Aug 21, 2023

#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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug mypy got something wrong topic-type-alias TypeAlias and other type alias issues
Projects
None yet
Development

No branches or pull requests

7 participants