Skip to content

Incompatible ParamSpecs are not reported as issue #17766

Open

Description

@pfaion

Bug Report

Hey everyone, thanks for all the great work on mypy!

I ran into an issue where mypy does not warn if 2 ParamSpecs need to align but they don't. Hard to describe, so here's an example:

To Reproduce

from collections.abc import Callable
from typing import ParamSpec

P = ParamSpec("P")

def foo(cb1: Callable[P, None], cb2: Callable[P, None]) -> None:
    pass
  
def my_callback_1(x: int) -> None:
    pass

def my_callback_2(x: str) -> None:
    pass

foo(my_callback_1, my_callback_2)

Playground link: https://mypy-play.net/?mypy=latest&python=3.12&flags=strict&gist=f2fac01dde7a953c5dec5117428350f6

Expected Behavior

I think mypy should error out on the foo() call since it's not possible to resolve both Callables to a common ParamSpec P.

When I put the same code into VSCode, pylance reports the following error:

Argument of type "(x: str) -> None" cannot be assigned to parameter "cb2" of type "(**P@foo) -> None" in function "foo"
  Type "(x: str) -> None" is not assignable to type "(x: int) -> None"
    Parameter 1: type "int" is incompatible with type "str"
      "int" is not assignable to "str"
Pylance[reportArgumentType](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#reportArgumentType)

Actual Behavior

mypy does not report any issues:

Success: no issues found in 1 source file

Your Environment

See the playground link above, but when I run into this locally, I have:

  • Mypy version used: mypy 1.11.2 (compiled: yes)
  • Mypy command-line flags: --strict
  • Mypy configuration options from mypy.ini (and other config files): None
  • Python version used: Python 3.12.2

Activity

  1. changed the title [-]Incompatible ParamSpecs are not causing any issue[/-] [+]Incompatible ParamSpecs are not reported as issue[/+] on Sep 13, 2024
  2. sterliakov commented on Feb 5, 2025

    @sterliakov
    Collaborator

    I'm not sure what the right course of action is here.

    First of all, P in the snippet can be satisfied by def _(x: Never) -> None, and that's exactly how mypy solves these constraints. Function with such signature can exist, so formally there is no bug.

    def foo(cb1: Callable[P, None], cb2: Callable[P, None]) -> Callable[P, None]:
        pass
      
    def my_callback_1(x: int) -> None:
        pass
    
    def my_callback_2(x: str) -> None:
        pass
    
    reveal_type(foo(my_callback_1, my_callback_2))  # N: Revealed type is "def (x: Never)"

    mypy does detect truly non-overlapping signatures (e.g. def f(x: int) -> None and def g(*, y: str) -> None are rejected in foo(f, g)). It doesn't:(

    However, such functions are of rather limited use (assert_never is one of them), and the requested behaviour is more intuitive and useful. This behaviour certainly needs to be formalized first: should that be "all arguments inferred to Never"? "at least one argument inferred to Never"? Something else?

    Current spec does not explicitly define incompatible ParamSpec caused by one or more args with constraints satisfied only by bottom type:

    Just as with traditional TypeVars, a user may include the same ParamSpec multiple times in the arguments of the same function, to indicate a dependency between multiple arguments. In these cases a type checker may choose to solve to a common behavioral supertype (i.e. a set of parameters for which all of the valid calls are valid in both of the subtypes), but is not obligated to do so.

    # Quoted from typing spec
    P = ParamSpec("P")
    
    def foo(x: Callable[P, int], y: Callable[P, int]) -> Callable[P, bool]: ...
    
    def x_y(x: int, y: str) -> int: ...
    def y_x(y: int, x: str) -> int: ...
    
    foo(x_y, x_y)  # Should return (x: int, y: str) -> bool
                   # (a callable with two positional-or-keyword parameters)
    
    foo(x_y, y_x)  # Could return (a: int, b: str, /) -> bool
                   # (a callable with two positional-only parameters)
                   # This works because both callables have types that are
                   # behavioral subtypes of Callable[[int, str], int]
    
    
    def keyword_only_x(*, x: int) -> int: ...
    def keyword_only_y(*, y: int) -> int: ...
    foo(keyword_only_x, keyword_only_y) # Rejected

    There's nothing technically challenging in changing this behaviour:

    diff --git a/mypy/applytype.py b/mypy/applytype.py
    index e87bf939c..e443f98db 100644
    --- a/mypy/applytype.py
    +++ b/mypy/applytype.py
    @@ -43,6 +43,10 @@ def get_target_type(
         if isinstance(p_type, UninhabitedType) and tvar.has_default():
             return tvar.default
         if isinstance(tvar, ParamSpecType):
    +        if isinstance(p_type, Parameters) and any(isinstance(get_proper_type(p), UninhabitedType) for p in p_type.arg_types):
    +            if skip_unsatisfied:
    +                return None
    +            report_incompatible_typevar_value(callable, type, tvar.name, context)
             return type
         if isinstance(tvar, TypeVarTupleType):
             return type
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions