Constraining generic argument types

Please consider the following code:

from typing import Callable, TypeVar

_T = TypeVar("_T")

def simple(x: _T, y: _T) -> _T:
    return x

def complex(x: Callable[[str], _T], y: _T) -> _T:
    return y

reveal_type(simple(123, ""))
reveal_type(complex(int, ""))

Running pyright on it returns a type of int | str for both functions, generating a union of the actual argument types. mypy on the other hand is inconsistent here. In the simple case it infers a return type of object (which is mypy’s version of combining the types from both arguments), while it errors out in the complex case:

Argument 1 to "complex" has incompatible type "type[int]"; expected "Callable[[str], str]"

What is the correct behavior here? It doesn’t seem to be spec’ed. I naïvely assumed that mypy’s handling of the complex case is the correct one: The types of all supplied arguments must match. Otherwise it’s hard to enforce matching generic argument types. (As in the case of the original issue where this problem arose: https://github.com/python/typeshed/pull/12211.) mypy’s behavior in the complex case would agree. But both pyright’s behavior as well as mypy’s behavior in the simple case indicate otherwise.

I like what Pyright does here and would lean towards codifying something like it.


I also did a quick check of what other type checkers are doing here:

pyre also agrees with pyright for this behavior

pytype determines Any for the simple case, which while that avoids false positives, also significantly increases the potential for false negatives. (whereas mypy determining object, and pyre and pyright choosing the union is less likely to have false positives or false negatives since the union should need disambiguation by users to safely use the parts that don’t come from object)

(I’m unaware of a pytype playground to link to, I took your example and added an import from typing extensions for reveal_type, not other modifications)

it similarly errors for the complex case, but continues to consider that as having type of Any.

File "/home/michael/test.py", line 12, in <module>: Any [reveal-type]
File "/home/michael/test.py", line 13, in <module>: Function complex was called with the wrong arguments [wrong-arg-types]
         Expected: (x, y: int)
  Actually passed: (x, y: str)
File "/home/michael/test.py", line 13, in <module>: Any [reveal-type]

The built-in complex is being shadowed. Might that confuse type checkers?

If the intent really is to describe not just two generic functions, but a generic pair of functions, liked by a single generic type, I would put them on a class (a generic class) or a namespace.

Otherwise, I don’t know if this will fix it, but I would’ve thought it’s better to remove the coupling, and define two type variables, one for each.

The problem with that behavior is that it prevents us from enforcing that two arguments have the same, generic type. For example, this is the current annotation for shutil.copyfileobj:

def copyfileobj(fsrc: SupportsRead[AnyStr], fdst: SupportsWrite[AnyStr], length: int = 0) -> None: ...

This is meant to enforce that both arguments are either text or binary streams. Mixing them is not possible. Annotations like this are fairly common.

On the other hand, I’m not sure what we gain from the “merging” behavior.

I don’t particularly like this example within the current specification, but I understand the need for it. This seems like trying to use the way generics work in classes (1 type to many uses relationship) but there’s no way to really spell this out right now for functions (subscriptable functions should cause this to have the behavior you want here) and I think it’s generally better to just leave things untyped and use that as a motivation to improve available typing constructs when we have cases like this.

Better overall inference in my experience. This was a dealbreaker for me at one point on using mypy, while I don’t have a good example that I am permitted to share, this difference in behavior caused pyright to handle an async scheduling dispatch mechanism significantly better than mypy.

Without the merging behavior code that passes type-checking could produce type errors if assertions (that pass at runtime) were added. This would be extremely perplexing because it would mean you could make a function call pass type checking by broadening its input arguments!

For example:

from typing import TypeVar, reveal_type

_T = TypeVar("_T")

def f(x: _T, y: _T) -> _T:
    return x


a: int | str = 1
b: int | str = ""
reveal_type(f(a, b))
assert(isinstance(a, int))
assert(isinstance(b, str))
# Do various things with a and b that require these types.
reveal_type(f(a, b))  # Would be broken without merging.
1 Like

To me, there’s a “direction of code-flow” based answer here

def foo(x: T, y: T) -> T:
    ...

is a many-to-1 relationship.

class foo(Protocol[T]):
    def __call__(self, x: T, y: T):
        ...

is a 1-to-many relationship

def foo(x: T, y: T) -> None:

is ambiguous here, there’s no other point in code flow to have a relationship, so there’s not a way to say whether these have the same type by virtue of just picking a type they share or have a different type because at runtime they are slightly different.

Surely no compliant type checker can ever assign str | bytes (or Any for that matter) to AnyStr? Since it’s specifically defined as TypeVar('AnyStr', str, bytes), i.e. a constraint type variable.

1 Like

The problem with the copyfileobj example is that a type checker doesn’t catch an error when using merging behavior:

from _typeshed import SupportsRead, SupportsWrite
from shutil import copyfileobj

x: SupportsRead[str]
y: SupportsWrite[bytes]
copyfileobj(x, y)  # mypy: Cannot infer type argument 1 of "copyfileobj"

pyright currently accepts this, mypy doesn’t.

Of course, in the specific case of AnyStr, this is fairly easily worked around using overloads, but overloads won’t work in other cases. For example the fairly common case where one argument is passed into another argument, which is a callable.

So pyre has the same beahvior as pyright for your original unconstrained case (complex) but actually detects issues with that matching constraints in type variables still. It detects this isn’t possible to make a Union out of. Of all of the behaviors observed for this, I think Pyre would be the clear one to model in specification.

Edit: It looks like pyright is getting this right too, I had assumed from context that it wasn’t.

At a high level, the job of a constraint solver is to collect all of the constraints on a type variable and determine whether there is some type that satisfies all of those constraints. If such a type exists but a constraint solver fails to find it, that’s arguably a bug in the constraint solver. From that perspective, this simply looks like a bug in mypy.

Constraint solving can be very costly, so implementations take certain shortcuts and make certain assumptions. These can fail to find certain solutions. I suspect that’s what’s happening in this case for mypy.

Constraint solving behaviors are not discussed in the typing spec currently, and I think it will be a long time (if ever) before they are. Spec’ing this behavior would be very difficult as it involves many heuristics, edge cases, behaviors that differ between type checkers, and limitations dictated by internal implementation choices. It also relies on other concepts (like value-constrained TypeVars) that are not yet well spec’ed. If you want to pursue standardization in this space, it will take significant time and effort.

While a full specification of the type resolver might be a complicated task, deciding the correct behavior in this user visible case of generic function signatures would be important – even without a full specification. Currently, quite a few annotations in typeshed work on the assumption that the “matching” behavior (partly implemented by mypy for complex cases) as opposed to the “merging” behavior (as implemented by pyright and by mypy for simple cases) is the correct one.

If we decide that the merging behavior is the correct one, we’d need to rethink those annotations, as they currently don’t work correctly with pyright (and possibly mypy). On the other hand, it would also important to know if the matching behavior is correct, so we can continue to use it.

Could you provide an example? It doesn’t seem to me that linked one is problematic.

Just as food for thought, notice how PyRight does the right think with this:

def g(x: Callable[[], _T], y: MutableSequence[_T]) -> None:
    y.append(x())

s: list[str] = []
g(int, s)  #  Error, as expected.

So, Pyright is ensuring that when no _T works, there is an error. And it is ensuring (for the cases we’ve seen so far) that when some _T works , there is no error.

See the linked PR in the original post, where the tests currently fail for pyright, but not mypy, and the copyfileobj() example.

See the linked PR in the original post,

Did you mean this?

    @overload
    def add_argument(
        self,
        *name_or_flags: str,
        action: _ActionStr | type[Action] = ...,
        nargs: int | _NArgsStr | _SUPPRESS_T | None = None,
        const: Any = ...,
        default: Any = ...,
        type: Callable[[str], _T],
        choices: Iterable[_T] | None = ...,

Can you elaborate on why you think this should be an error?

the copyfileobj() example

a: SupportsRead[bytes] = BinaryIO()
b: SupportsWrite[str] = TextIO()

copyfileobj(a, b)

gives “Incompatible arguments” in PyRight and MyPy. Did you mean something else?

Sorry, I don’t see the value in discussing the specifics of individual examples, especially as I’ve already done so above. In both cases, mypy and pyright come to a different conclusion. That’s the point of this discussion.

Sorry, I think I’m confused about what your post is about then. You asked “what is the correct behavior [for the given example]?”. And you also said that “quite a few annotations work based on the matching behavior”. Then don’t you think we should examine the examples to see what the behavior should be?

It seems to me that Pyright is doing the right thing in both of the examples you gave. If you think MyPy’s behavior is right, I’m curious to know why. It seems to me, just based on what you’ve linked, that these are simply bugs in MyPy coupled with misunderstandings?

I’ve already done so above.

Just looking at the copyfileobj example again, PyRight is giving me an error:

def f(a: SupportsRead[bytes], b: SupportsWrite[str]):
    copyfileobj(a, b)  # Error

Sorry Neil, I misread what you were responding to. I was under the impression we were discussing whether it makes sense to find the correct behavior, not which behavior is correct.

But your modified copyfileobj() behavior is interesting. It seems that pyright (and mypy) handle constrained type vars differently using the match semantics:

from _typeshed import SupportsRead, SupportsWrite
from typing import TypeVar

_V = TypeVar("_V", bound=str | bytes)
_T = TypeVar("_T", str, bytes)
_U = TypeVar("_U")

def bound(r: _V, w: _V) -> None:
    pass

def constrained(r: _T, w: _T) -> None:
    pass

def unbound(r: _U, w: _U) -> None:
    pass

def call_bound(x: str, y: bytes) -> None:
    bound(x, y)

def call_constrained(x: str, y: bytes) -> None:
    constrained(x, y)  # error with both mypy and pyright

def call_unbound(x: str, y: bytes) -> None:
    unbound(x, y)

This would make the AnyStr cases (fortunately) unproblematic.

While looking for another example, I came across an interesting difference:

from typing import Callable, TypeVar

_T = TypeVar("_T")

def arg(it: _T, cb: Callable[[_T], int]) -> _T: ...
def ret(it: _T, cb: Callable[[str], _T]) -> _T: ...

def int_to_int(x: int) -> int:
    return x + 1

def str_to_int(x: str) -> int:
    return 42

reveal_type(arg(1, int_to_int))  # int
reveal_type(arg(1, str_to_int))  # error
reveal_type(ret(1, int))  # int  
reveal_type(ret(1, str))  # pyright int | str, mypy error

So it seems that pyright does indeed sometimes use “matching” behavior. In this case if the type var is used as an input type to the callable.

All of this is quite confusing.

1 Like

Thank you for taking the time to analyze the problem :smile:

I think this has to do with covariance and contravariance. Consider what Eric said about type checking being constraint solving. The constraint in this case:

reveal_type(arg(1, str_to_int))  # error

is to find a type form _T such that int < _T and Callable[[str], int] < Callable[[_T], int]. int doesn’t work, and neither does str | int. This is because if a function needs a Callabe[[str | int], int], then a Callable[[str], int] isn’t good enough.

Whereas here:

reveal_type(ret(1, str))  # pyright int | str, mypy error

The analogous constraint can be solved since if the function needs Callable[[int], int | str], then passing Callable[[int], str] is fine.

As I mentioned above, the job of a constraint solver is to determine all of the constraints on a type variable for a given function call. Based on these constraints, it then attempts to find some type (preferably, an “optimal” type by some definition of the word “optimal”) that satisfies all of the constraints.

These constraints come from various sources:

  1. Upper bounds on the type variable, in the case of normal TypeVars
  2. Value constraints, in the case of value-constrained TypeVars
  3. Variance rules
  4. (Bidirectional) inference context
  5. Explicit specialization, in the case of constructor calls
  6. Arguments passed to the call

Let’s examine a few of your examples in detail. This might help clarify the behaviors in your mind.


Example 1: constrained(x, y)

Here we are using a value-constrained TypeVar, and the solution must match one of the specified value constraints exactly. That means we start with the constraint that the solution to _T must be either str or int.

The first argument x imposes the constraint _T = str. The second argument imposes the constraint _T = int. There is no possible type that satisfies these constraints, so this is an error.


Example 2: arg(1, str_to_int)

In this case, there is no upper bound, value constraint, inference context, or explicit specialization. All of the constraints come from arguments and variance rules.

The first argument 1 is of type int, and it imposes the constraint _T ≥ int. This is a covariant context.

The second argument str_to_int is of type (x: str) -> int, and it imposes the constraint _T ≤ str. This is a contravariant context because it’s an input parameter for a Callable. That explains why the constraint uses rather than .

There is no type that can possibly satisfy both of these constraints, hence the error.


Example 3: ret(1, str)

Once again, all of the constraints come from arguments and variance rules.

The first argument 1 once again imposes the constraint _T ≥ int. This is a covariant context.

The second argument str is of type type[str] and can be converted to a callable type because it’s a class object with a constructor. The converted type is effectively (object) -> str. This imposes the constraint _T ≥ str. This is a covariant context because it’s a return type for a Callable.

These two constraints allow for solutions str | int or object.


Note: I’ve left out a bunch of details above such as handling of literal types and handling of overloaded constructors when converting to a callable, but I wanted to keep the examples relatively simple.

4 Likes