python / python/mypy

`ParamSpec` inferred overly wide from protocol

Open
#21,384 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug topic-inference topic-paramspec
Dominant language
Python
Stars
20.6k
Forks
3.3k
PR merge metrics
PR metrics pending

Description

Bug Report

When implementing a protocol generic wrt a ParamSpec type inference ends up inferring Any for the param spec in certain cases. Writing code around the way the type checker works we can force it to unify against a properly inferred param spec, but this limits our ability to write nice APIs.

To Reproduce

from typing import Protocol, reveal_type

class Context: pass

class Namer[**P](Protocol):
    def name_for(self, *args: P.args, **kwargs: P.kwargs) -> str: ...

    def execute_on(
        self, ctx: Context, *args: P.args, **kwargs: P.kwargs
    ) -> None: ...

class Impl0:
    @staticmethod
    def name_for(x: int, y: str) -> str:
        return 'Test'
    @staticmethod
    def execute_on(ctx: Context, x: int, y: str):
        pass
    
class Impl1:
    @staticmethod
    def name_for(y: str) -> str:
        return 'Test'
    @staticmethod
    def execute_on(ctx: Context, x: int, y: str):
        pass

class UseImplFirst[**P, T]:
    def __init__(self, impl: T, *args: P.args, **kwargs: P.kwargs) -> None:
        self.impl = impl
        self.args = args
        self.kwargs = kwargs
    def __call__(self: UseImplFirst[P, Namer[P]]) -> None:
        pass

def useImplSecond[**P](impl: Namer[P], *args: P.args, **kwargs: P.kwargs):
    pass

def useImplThird[**P](wrapper: UseImplFirst[P, Namer[P]]) -> None:
    pass

__testImpl0: Namer[[int, str]] = Impl0
UseImplFirst(Impl0, 0, '0')()
useImplSecond(Impl0, 0, '0')
useImplThird(UseImplFirst(Impl0, 0, '0'))

__testImpl1: Namer[[int, str]] = Impl1    # Should fail (Fails)
UseImplFirst(Impl1, 1, '1')()             # Should fail (Succeeds)
useImplSecond(Impl1, 1, '1')              # Should fail (Succeeds)
useImplThird(UseImplFirst(Impl1, 1, '1')) # Should fail (Succeeds)

Expected Behavior / Actual Behavior

See the snippet above.

Woraround
The following forces mypy to infer the ParamSpec first and unify against the protocol implementation later:

class UseImpl[**P]:
    def __init__(self, *args: P.args, **kwargs: P.kwargs):
        self.args = args
        self.kwargs = kwargs
    def __call__(self, impl: Namer[P]) -> None:
        pass


__testImpl0: Namer[[int, str]] = Impl0
UseImpl(0, '0')(Impl0)

__testImpl1: Namer[[int, str]] = Impl1 # Should fail (Fails)
UseImpl(1, '1')(Impl1)                 # Should fail (fails)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running the reproducer from the issue and comparing the mypy 1.12.0 and 1.20.2 playgrounds. Use the expected failures for Impl1 and the working workaround as the completion criteria, then add regression coverage in the relevant type-inference tests once the implementation area is identified.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
compilers
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.