Closed Drino closed 1 year ago
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!
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>]"
One more thing: function b
produces an error: Type variable "ex.T" is unbound
, while a
does not
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
in basedmypy typevars are allowed in the bound of other typevars.
@KotlinIsland sorry, I don't understand. Can you please clarify?
Oh, my mistake, this is a TypeVar
in an alias, not a TypeVar
in the bound of a TypeVar
, sorry
(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"
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.
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.
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
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.)
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 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
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).
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.
Bug Report
A
Callable
type alias is handled like usual generic, not asCallable
. 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 viaParamSpec
.To Reproduce
Run mypy on following code:
Expected Behavior
According to docs:
So
def factory_with_bound_type_alias() -> TDecorator[TCallable]:
should work.But to be honest this is pretty counterintuitive.
I'd expect:
Generally speaking I'd expect unbound type variables in
Callable
type alias to be bound to its call scope, not filled withAny
.It already works this way with Callable itself:
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:Your Environment
gist mypy-playground
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: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