Skip to content

mypy doesn't escape typevars from generics in self-type #18400

Open

Description

@A5rocks

Bug Report

Passing a callable to another which matches the typevar with another typevar doesn't allow passing them into the return type. (if that makes no sense, the reproducer below is simple)

To Reproduce

from typing import Generic, TypeVar, Callable

T = TypeVar("T")
V = TypeVar("V")

class X(Generic[T]):
    def f(self: X[Callable[[V], None]]) -> Callable[[V], V]:
        def inner_f(v: V) -> V:
            return v

        return inner_f

reveal_type(X[Callable[[T], None]]().f())  # N: Revealed type is "def (Never) -> Never"
# ^ this should keep the generic

Expected Behavior

I don't expect a Never.

Your Environment

Checked on mypy playground.

  • Mypy version used: v1.14
  • Mypy command-line flags: --strict
  • Mypy configuration options from mypy.ini (and other config files): N/A
  • Python version used: 3.12

Activity

  1. changed the title [-]mypy doesn't escaped stored typevartuples in a self-type[/-] [+]mypy doesn't escape stored typevartuples in a self-type[/+] on Jan 1, 2025
  2. A5rocks commented on Jan 1, 2025

    @A5rocks
    CollaboratorAuthor

    Turns out this applies to TypeVars too -- I'll update this issue for that (since it's simpler).

  3. changed the title [-]mypy doesn't escape stored typevartuples in a self-type[/-] [+]mypy doesn't escape stored typevars in a self-type[/+] on Jan 1, 2025
  4. changed the title [-]mypy doesn't escape stored typevars in a self-type[/-] [+]mypy doesn't escape typevars from a passed in function[/+] on Jan 1, 2025
  5. changed the title [-]mypy doesn't escape typevars from a passed in function[/-] [+]mypy doesn't escape typevars that partially match[/+] on Jan 1, 2025
  6. changed the title [-]mypy doesn't escape typevars that partially match[/-] [+]mypy doesn't escape typevars from generics in self-type[/+] on Jan 1, 2025
  7. sterliakov commented on Jan 10, 2025

    @sterliakov
    Collaborator

    Hm. Are you sure this is supposed to work at all? There is no precedent of bare typevar at top level working like this, otherwise we'd have some fancy features like dict[T, Callable[[T], None]] top-level type map to object handlers which, I believe, have never been supported (and maybe shouldn't be - what does the spec say?).

    As a rule of thumb, there's no way to spell your repro using horrible PEP695 syntax, so the typevar is used in a wrong scope. Mypy should produce an error right there ("Type variable T is unbound"), and absence of that diagnostic is indeed a bug.

    If I fix your example to put the typevar in scope, mypy does what you expected:

    from typing import Generic, TypeVar, Callable
    
    T = TypeVar("T")
    V = TypeVar("V")
    
    class X(Generic[T]):
        def f(self: X[Callable[[V], None]]) -> Callable[[V], V]:
            def inner_f(v: V) -> V:
                return v
    
            return inner_f
    
    
    def fn(_: T) -> None:
        reveal_type(X[Callable[[T], None]]().f())  # N: Revealed type is "def (T`-1) -> T`-1"

    plaground

  8. A5rocks commented on Jan 10, 2025

    @A5rocks
    CollaboratorAuthor

    Hm. Are you sure this is supposed to work at all? There is no precedent of bare typevar at top level working like this, otherwise we'd have some fancy features like dict[T, Callable[[T], None]] top-level type map to object handlers which, I believe, have never been supported (and maybe shouldn't be - what does the spec say?).

    This should be valid because callables capture their generic. I'm also just doing this for a more simple reproducer. Here's the less minimized version of my failing example:

    from __future__ import annotations
    
    from dataclasses import dataclass
    from typing import TypeVarTuple, Unpack, Generic, TypeVar, Any, overload
    from collections.abc import Callable
    
    # TODO: hide this in an internal file so that I can have descriptive names without polluting namespace
    Args = TypeVarTuple("Args")
    
    class Build(Generic[Unpack[Args]]):
        pass
    
    class Built(Generic[Unpack[Args]]):
        pass
    
    From = TypeVarTuple("From")
    To = TypeVarTuple("To")
    InitialCallable = TypeVar("InitialCallable", bound=Callable[[Build[Unpack[tuple[Any, ...]]]], Build[Unpack[tuple[Any, ...]]]])
    
    @dataclass
    class Builder(Generic[InitialCallable]):
        initial_callable: InitialCallable
    
        def __call__(self: Builder[Callable[[Build[Unpack[From]]], Build[Unpack[To]]]]) -> Callable[[Callable[[object, Unpack[To]], None]], Built[Unpack[From]]]:
            def build_up(in_progress: Callable[[object, Unpack[To]], None]) -> Built[Unpack[From]]:
                ...
    
            return build_up
    
    def make_builder() -> Callable[[Callable[[Build[Unpack[From]]], Build[Unpack[To]]]], Builder[Callable[[Build[Unpack[From]]], Build[Unpack[To]]]]]:
        def inner_make_builder(command: Callable[[Build[Unpack[From]]], Build[Unpack[To]]]) -> Builder[Callable[[Build[Unpack[From]]], Build[Unpack[To]]]]:
            return Builder(command)
    
        return inner_make_builder
    
    from typing import Unpack, TypeVarTuple
    
    #Args = TypeVarTuple("Args")
    
    def test_basic_command() -> None:
    
        @make_builder()
        def command_builder(b: Build[Unpack[Args]]) -> Build[Unpack[Args]]:
            return b
        
        reveal_type(command_builder())  # N: Revealed type is "def (def (builtins.object, *Never)) -> __main__.Built[Unpack[builtins.tuple[Never, ...]]]"
        # ^ I expect this to work

    To prove this should work, I've turned Builder.__call__ into an explicit call to Builder_call and removed use of a self-type:

    from __future__ import annotations
    
    from dataclasses import dataclass
    from typing import TypeVarTuple, Unpack, Generic, TypeVar, Any, overload
    from collections.abc import Callable
    
    # TODO: hide this in an internal file so that I can have descriptive names without polluting namespace
    Args = TypeVarTuple("Args")
    
    class Build(Generic[Unpack[Args]]):
        pass
    
    class Built(Generic[Unpack[Args]]):
        pass
    
    From = TypeVarTuple("From")
    To = TypeVarTuple("To")
    InitialCallable = TypeVar("InitialCallable", bound=Callable[[Build[Unpack[tuple[Any, ...]]]], Build[Unpack[tuple[Any, ...]]]])
    
    @dataclass
    class Builder(Generic[InitialCallable]):
        initial_callable: InitialCallable
    
    def Builder_call(self: Builder[Callable[[Build[Unpack[From]]], Build[Unpack[To]]]]) -> Callable[[Callable[[object, Unpack[To]], None]], Built[Unpack[From]]]:
        def build_up(in_progress: Callable[[object, Unpack[To]], None]) -> Built[Unpack[From]]:
            ...
    
        return build_up
    
    def make_builder() -> Callable[[Callable[[Build[Unpack[From]]], Build[Unpack[To]]]], Builder[Callable[[Build[Unpack[From]]], Build[Unpack[To]]]]]:
        def inner_make_builder(command: Callable[[Build[Unpack[From]]], Build[Unpack[To]]]) -> Builder[Callable[[Build[Unpack[From]]], Build[Unpack[To]]]]:
            return Builder(command)
    
        return inner_make_builder
    
    from typing import Unpack, TypeVarTuple
    
    #Args = TypeVarTuple("Args")
    
    def test_basic_command() -> None:
    
        @make_builder()
        def command_builder(b: Build[Unpack[Args]]) -> Build[Unpack[Args]]:
            return b
        
        reveal_type(Builder_call(command_builder))  # N: Revealed type is "def [From] (def (builtins.object, *Unpack[From`330])) -> __main__.Built[Unpack[From`330]]"
        # ^ now it works!
  9. A5rocks commented on May 3, 2025

    @A5rocks
    CollaboratorAuthor

    Here's a much smaller and more obviously correct reproduction:

    import dataclasses
    from typing import Protocol, TypeVar, ParamSpec, Generic, Callable
    
    T = TypeVar("T", covariant=True)
    P = ParamSpec("P")
    
    class Meta(type):
        def __call__(cls, *args: object, **kwargs: object) -> None:
            raise TypeError(
                f"{cls.__module__}.{cls.__qualname__} has no public constructor",
            )
    
        def _create(cls: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
            return super().__call__(*args, **kwargs)  # type: ignore
    
    @dataclasses.dataclass
    class X(Generic[T], metaclass=Meta):
        x: T
    
    
    reveal_type(X._create)  # N: Revealed type is "def (*args: Never, **kwargs: Never) -> Never"

    (the main point of contention I see is that cls: Callable[P, T] should maybe match __call__, but that would have (*args: object, **kwargs: object) and just isn't the case currently.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugmypy got something wrong

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions