Repository navigation
Unexpected type binding when wrapping functions #12919
Description
Bug Report
I admit that I do not know if this is a bug, and not just my incomplete understanding of how the type system works, but I have run into some unexpected behaviour, and behaviour where mypy and pyre disagree, so it might be a bug.
I am writing code that lifts functions T -> R to Optional[T] -> Optional[R] in various ways, and when I write wrapper functions, type variables get bound in ways I didn't expect. It is particularly bad when I add protocols for various types on top of it, but I can create the unexpected behaviour without it. Basically, the issue is that if I write a function that changes a Callable[[T], R] into a Callable[[Optional[T]], Optional[R]], and then apply such a function to another generic function Callable[[T],R] the type variables are no longer generic, and I cannot apply the wrapped function. This happens both when I work with wrapper functions or when I implement the wrapper as a Generic class.
To Reproduce
If I write a wrapper with a function, it might look like this:
from typing import (
TypeVar,
Protocol, Generic,
Optional as Opt
)
_T = TypeVar('_T')
class _F(Protocol[_T]):
def __call__(self, __x: _T) -> _T:
...
# Lifting with a function...
def lift(f: _F[_T]) -> _F[Opt[_T]]:
# This function would do more, but I just return f for the
# type checking example
return f # type: ignoreI get the desired behaviour if I wrap a function with a concrete type
def f(x: int) -> int:
return x
reveal_type(lift(f)) # _F[Opt[int]] -- as expectedbut if I use a generic type, the wrapped function is not generic:
def g(x: _T) -> _T:
return x
reveal_type(lift(g)) # _F[Union[_T`-1,None]] -- _T is bound (incorrectly?)
# pyre thinks this is _F[None] which is also odd...
lift(g)(1) # so won't accept intExpected Behavior
I was expecting the wrapped function to have the type Callable[[Opt[_T]], Opt[_T]] and in particular I would expect to be able to call it with an int argument.
To Reproduce
If I use a Generic class for the lifted function, it can look like this:
# Moving generic type to Generic...
class lifted(Generic[_T]):
"""Lift type _T to Opt[_T]."""
def __init__(self, f: _F[_T]) -> None:
self._f = f
def __call__(self, x: Opt[_T], /) -> Opt[_T]:
"""Call lifted."""
return self._f(x) # type: ignore
reveal_type(lifted[_T]) # _F[T?] -> lifted[_T?]and it will work for a function with a concrete type, like f: int -> int above
reveal_type(lifted(f)) # lifted[int] -- perfect
reveal_type(lifted(f).__call__) # Opt[int] -> Opt[int] -- perfect
lifted(f)(1) # We can call with int
lifted(f)(None) # We can call with Nonebut things get crazy when I use a generic function like g: _T -> _T:
reveal_type(lifted(g)) # lifted[_T`1] -- _T is bound!
# pyre thinks (correctly I think) it is lifted[_T]
reveal_type(lifted(g).__call__) # Opt[_T`1] -> Opt[_T`1]
# pyre says Any -> Any which I suppose is fine
lifted(g)(1) # incompatible type, int isn't an Opt[_T]
# pyre seems to accep this one
lifted(g)(None) # but at least None is in Opt[_T]...
reveal_type(lifted[_T](g)) # lifted[_T?] -- Ok
reveal_type(lifted[_T](g).__call__) # def (None) ???
# pyre: Opt[_T] -> Opt[_T] as expected
lifted[_T](g)(1) # incompatible type "int"; expected "None"
lifted[_T](g)(None) # but at least None works...
# Pyre doesn't like the [_T] here but accepts the calls without it...Expected Behavior
Again, I would expect the Generic lifted class to accept types that match the wrapped function's type. This is more important when I need to add protocols to the types, but I would also expect it to work here.
Your Environment
I tested this in mypy playground and pyre playground
Activity
erictraut commentedon Jun 1, 2022
Type variables are "solved" in the context of a call expression that targets the constructor of a generic class or a generic function. Only the type variables in the scope of that class or function are solved. Once a type variable goes out of scope, it can no longer be solved.
The expression
listed(g)causes mypy to solve the TypeVar_T@lifted(i.e._Tin the scope of classlifted). The solution in this case is_T@g, which means thatlisted(g)evaluates to typelifted[_T@g]. However, at this point,_T@gis no longer in scope. It's an unsolved TypeVar from the scope of functiong. It is not associated with theliftedclass or its__call__method, which means it cannot be solved in subsequent call expressions likelifted(g)(1).FWIW, pyright largely agrees with mypy in this case.
So I don't think this is a bug in mypy. It's expected behavior.
mailund commentedon Jun 1, 2022
Ok, I think I understand what you are saying, @erictraut, but let me just check. If I use a generic function or class with a type variable, I bind it when I apply it, and it is no longer functioning as a type variable. If I want the wrapped function to work, the wrapping has to know the concrete type.
So, for example, with a curry function (as a
Generic)I can't do something like
because
_Tisn't free after wrapping, and I cannot do this eithereither, because I can't use a type variable like this when it is unbound?
I can, however, use it with concrete types
or I can use
_Tinside a function that uses the type variable because there it will be bound when I evaluate the function and thus get a concrete type there.As long as I don't use an unbound type variable when instantiating the
Generichere, it seems to work. Is that guaranteed, and is that the way to achieve what I tried above?sterliakov commentedon Jun 2, 2022
Just adding my 2 cents here, because I've faced this problem once before (and ended with very ugly pattern that violates DRY, see below).
Related: #1317
It is understandable why does
mypy"solve" this variable earlier than we want. But there is no way to define the following decorator: "given function that takes fixed arguments set (say,f(x, y)that implementsminsignature, likef(x: _T, y: _T) -> _T), produce a function that has same signature with typesTransformed[_T]", whereTransformedis expressible withtypingprimitives and depends only on_T(propagatingOptionalis just one of possible use cases). It is important that_Tis the same for decorated function and result, but we do not know_Tvalue in advance.I think that the following (very raw) approach should be better:
Given decorator:
After producing
F[_P | None]type checker should recognize the fact that_Pis used both on LHS and RHS ofCallabledefinition. When it happens,_Pshould go back toFscope, allowing the expected substitution with respect toSupportsIntbound.The same should happen is resulting type is
GenericorProtocol: instead of producing generic class with unsolvable type variable,mypyshould keep it unresolved. Now any function that performs similar operation (f(x: G[_T]) -> G[_T | None], whereGis anyProtocolorGeneric) is malformed: when applied to generic with type variableG[_T], it producesG[_T | None]with_Tout of scope, so onlyAnyandNoneare compatible. It is definitely not the desired behavior.I suppose that no existing code should be broken after such change. The resolution algorithm now produces unusable functions (they don't take expected arguments), so usage of such decorators must be very rare and complex. When decorated function is using concrete types, this approach will change nothing.
The only solution now is to explicitly re-declare type of decorated function. But why are they compatible, if
foo(1)is OK whileallow_none(_foo)(1)throws mypy error:E: Argument 1 has incompatible type "int"; expected "Optional[_P]"? I don't know, it seems to be inconsistent, LHS type is wider likex: int = 0.1, isn't it?Foundations for non-linear solver and polymorphic application (#15287)