PEP 718: subscriptable functions

I will try to answer each of Carl’s toplevel bullets. Hopefully it’s not too Gargantuan a task.

Indeed. It behaves like generic builtins, which do no checking and return types.GenericAlias (e.g. list[int, str] is not caught at runtime); it does not act like generic user-defined classes, which return typing.Generic, which does validation.

I need to think hard about this one. You’re asking whether, after

@some_decorator
def func[T](x: T, n: int=1) -> list[T]:
    return [x] * n

can we write func[int]? I’d say that depends on whether type checkers today treat func as a generic function. If they do, subscription should be allowed too; if they don’t, it shouldn’t be.

I think the answer lies in the decorator’s signature. If it’s written as (Callable) -> Callable that sounds like it won’t be allowed. But if it’s written as (and now I’m making up syntax) [T: Callable](T) -> T it should – I tested in the mypy sandbox that such a decorator preserves genericity, so in that case thefunc should be subscriptable.

Okay, so for a generic function g, you can write wraps(g), but you can’t say wraps(g[int]). I think that’s a fine outcome, given that typical use of wraps is in a decorator, which (usually) takes a function definition. And if there’s a use case, wraps could easily be changed to special-case GenericAlias – not something that this PEP needs to prescribe (as long we have no strong use case for it).

Agreed. Let’s just cut that paragraph from the PEP.

Indeed. Let’s return an instance of a new class, FunctionGenericAlias, which subclasses GenericAlias. (This would make life easier for a future change to functools.wraps as well, see above.)

We need to think more about what to say. I’m no expert on overloading so I’ll decline giving it a try. But in the end it seems like it’s just an update to the PEP’s prose, not an objection to the proposal.

Ditto.

Tritto?

Quarto???

There’s a missing call – the example should be

C[int]().method[str](0, "")

Yeah, we should add a summary of that discussion (or its outcome) to the PEP.

Agreed that we need a prototype implementation. Are there any type checker authors here who like this idea enough to give that a try? (Carl, you’re involved in Ty – any appetite?)

The only alternative I see is for someone to use their free Fable budget to let a coding agent write a prototype. (Perhaps for mypy or pyright, since LLMs probably still have seen more code written in Python and TypeScript than in Rust during their training.)

Fully agree that most of my feedback was feedback on the PEP (to be addressed by expanding and clarifying the PEP text), not substantive feedback on the proposal. The important exception is this question:

Which I still think is quite a hard problem for the PEP.

Decorators are allowed to return generic callables, and nothing requires that the callables they return be function objects. Consider:

from typing import Callable

class Callback:
    def __call__[T](self, arg: T) -> T:
        return arg

def make_a_generic_callable[T](func) -> Callable[[T], T]:
    return Callback()

@make_a_generic_callable
def func(): ...

# `func` is now typed as a generic callable, so the below should be allowed?
# But this will error at runtime, because instances of `Callback` are not subscriptable.
func[int]

The problem is that “being a generic Callable” and “being an instance of one of the five function types supported at runtime for subscripting” are not the same thing.

I don’t think I will personally be able to drive this, but certainly willing to take a look at an implementation in ty if someone gives it a try.

from typing import Callable

class Callback:
    def __call__[T](self, arg: T) -> T:
        return arg

def make_a_generic_callable[T](func) -> Callable[[T], T]:
    return Callback()

@make_a_generic_callable
def func(): ...

# `func` is now typed as a generic callable, so the below should be allowed?
# But this will error at runtime, because instances of `Callback` are not subscriptable.
func[int]

I don’t think this is quite right, shouldn’t func be something like Callable[[Unknown], Unknown] in this case? Consider:

@make_a_generic_callable[int]
def func(): ...

then clearly, func would be Callable[[int], int], and not def [T: int](arg: T, /) -> T.

I would prefer your interpretation; that would be the consistent application of PEP 695 syntax in this scenario. T should be scoped to make_a_generic_callable, and thus if it is not determined by the call to make_a_generic_callable, it should fall back to Unknown.

Pre-695, there was an implicit (I don’t think it was written anywhere?) rule that in a case like def f() -> Callable[[T], T], T would be implicitly scoped to the returned callable rather than to f.

Unfortunately(?), all type checkers seem to have decided to continue applying that ad hoc rule, even when PEP 695 syntax is used in a way that should clarify the scoping of T. At root, this happens because people in the ecosystem do want to write decorators that return generic callables, and PEP 695 still does not provide any syntax for annotating a generic callable type. So we’ve followed suit in ty, to avoid breaking compatibility with that pattern (which we observed in real codebases.)

You can try my example and find that all type checkers (at least ty, pyright, mypy, pyrefly, zuban) reveal the type int or Literal[1] for reveal_type(func(1)).

I would be happy for a proposal to clearly specify the elimination of this wart, but I think practically it may require syntax for spelling a generic callable type in an annotation.

I also think PEP 718 should probably explicitly clarify how it interacts with this special case. Does your version with @make_a_generic_callable[int] re-scope T to the function itself instead of the return type, leading to a return of (int) -> int? Or is it an error, because T has already been scoped to the return callable, making make_a_generic_callable itself no longer generic, and that can’t be changed per-callsite? I suspect the latter is the version that would fall out of existing type checkers given an implementation of PEP 718 without any additional consideration of this case. Re-scoping type variables based on call-site usage seems very questionable and probably hard to implement.

EDIT: I guess we do already have syntax for spelling a generic callable type in an annotation: a callable Protocol with method-scoped generic __call__. This is a lot more verbose, but perhaps it’s enough to justify removing the re-scoping rule?

Yes this is something that can currently only be expressed with Callback protocols.

Eric once posted a nice overview here: PEP Idea: Extend spec of Callable to accept unpacked TypeDicts to specify keyword-only parameters - #24 by erictraut

If we ever get a nicer callable syntax, as suggested by PEP 677 or in PEP 677 with an easier-to-parse and more expressive syntax hopefully one would be able to express this “inline” without having to write a Callback Protocol every time.

FWIW I think lambda[T](arg: T) -> T would be a reasonable syntax. Once something like that is available, type checkers can probably deprecate the old behavior that conflicts with regular scoping rules. (or earlier with some transition period guiding users to use Callback Protocols instead)

1 Like

Someone should write a PEP for that! :wink:

2 Likes

Hi Carl! I used some of my Fable credit to have it write a prototype for mypy. It complied:

I have to admit that I didn’t review the code – it’s only a prototype --, but I ran it and it appears to work. It includes a fair amount of tests (including many edge cases) and they pass. You have to use it as mypy --enable-incomplete-feature SubscriptableFunctions to enable the new feature.

What we learned included a surprise: mypy strips unused typevars, and def f[T]() -> None: ... is not generic and cannot be indexed. We can’t find anything in the spec about this. The runtime is unambiguous: def f[T]() -> None: pass gives f.__type_params__ == (T,). Previously, this wasn’t observable in the type system except possibly through reveal_type() – PEP 718 makes it observable (you get an error on f[int] if f is not generic). Note that a generic class that doesn’t use its typevar is still considered generic, so this would be a wart whereby classes and functions don’t fully work the same way.

If we want such functions to be generic/subscriptable, the PEP should say so, and this would end up in the spec and mypy (and possibly others) would have to be changed. Alternatively, the PEP should explicitly say that unused typevars are stripped, and we should update the spec.

Note that this has consequences if only some of the typevars are used too – after def f[S, T](arg; T) -> T: ... the revealed signature is def [T] (arg: T) -> T and this affects subscripting: f[int] is valid but f[str, int] is not.


Going over Carl’s other questions, several of them have (partial) answers from the prototype:

  • Callable – the prototype allows subscription iff it sees a generic function (this is the natural checker rule) but doesn’t resolve the dilemma: it allows some subscriptions that fail at runtime (like functools.partial and lru_cache). Here I wonder: there’s a recent discussion about introducing more kinds of Callable types; maybe there could be a separate Callable subtype specifically for subscriptable Callables? I haven’t thought very deeply about this yet. We could also just accept that type checkers may occasionally accept things that fail at runtime. Aren’t there already examples of such elsewhere in the spec (not counting cast and Any)?

  • The prototype defines an overload algorithm that we should copy into the spec.

  • The questions about TypeVarTuple and ParamSpec are answered affirmatively; I am pasting the AI’s reply below (click “Details”).

Here are the worked examples, each paired with its class equivalent.

TypeVarTuple

python

def variadic[*Ts](*args: *Ts) -> tuple[*Ts]: ...

variadic[int, str]        # def (int, str) -> tuple[int, str]
variadic[int, str](1, "a")  # tuple[int, str]
variadic[int, str](1, 2)    # error: arg 2 expected str
variadic[int]             # def (int) -> tuple[int]
variadic[()]              # def () -> tuple[()]     — empty case works

Prefix and suffix TypeVars both bind correctly, matching the class forms exactly:

python

def pre[T, *Ts](x: T, *rest: *Ts) -> tuple[T, *Ts]: ...
pre[int, str, bytes]      # def (x: int, str, bytes) -> tuple[int, str, bytes]

def suf[*Ts, T](*args: *Ts, last: T) -> tuple[*Ts, T]: ...
suf[int, str, bytes]      # def (int, str, *, last: bytes) -> tuple[int, str, bytes]

That suffix case is Carl’s “TypeVarTuple followed by a regular TypeVar” question — it works, and greedy-middle absorption puts int, str in Ts and bytes in T, same as class SC[*Ts, T] yields SC[int, str, bytes].

PEP 646 unpacked-tuple disambiguation — his other explicit question — also works, in all three spellings:

python

variadic[*tuple[int, str]]        # def (int, str) -> tuple[int, str]
variadic[Unpack[tuple[int, str]]] # same
variadic[*tuple[int, ...]]        # def (*args: int) -> tuple[int, ...]

The homogeneous case behaves as for classes (VC[*tuple[int, ...]]), so the answer to “is it ever allowed?” is yes, with the same semantics.

ParamSpec

python

def wrap[**P, R](f: Callable[P, R]) -> Callable[P, R]: ...

wrap[[int, str], bool]   # def (f: def (int, str) -> bool) -> def (int, str) -> bool
wrap[..., bool]          # def (f: def (*Any, **Any) -> bool) -> ...
wrap[[], bool]           # def (f: def () -> bool) -> ...

Concatenate and ParamSpec passthrough, inside a scope where the ParamSpec is bound:

python

def scope(g: Callable[P2, None]) -> None:
    wrap[Concatenate[int, P2], bool]  # def (f: def (int, *P2.args, **P2.kwargs) -> bool) -> ...
    wrap[P2, bool]                    # def (f: def (*P2.args, **P2.kwargs) -> bool) -> ...

And both TypeVarTuple and ParamSpec together, Carl’s third question:

python

def both[*Ts, **P](*args: *Ts, cb: Callable[P, None]) -> None: ...
both[int, str, [bool]]   # def (int, str, *, cb: def (bool))

On whether the class rules apply

Confirmed for every form I tested, but let me be precise about what that confirms. I ran each function case against a structurally identical generic class and compared: bracketed lists, ..., empty list, the bare-single-argument shorthand (one[int] → [int], matching OC[int] → OC[[int]]), Concatenate, passthrough, arity errors, and the mixed TVT+ParamSpec case. Every pair agreed on the resulting parameter list. Where they errored, they errored identically — including an unbound-ParamSpec-at-module-scope case where both produced the same two diagnostics.

The caveat: this confirms mypy’s function path and mypy’s class path agree. It doesn’t independently verify that mypy’s class behavior is itself spec-conformant, and it’s one checker. The mechanism behind the agreement is that my semanal change routes functions through the same allow_unpack / has_param_spec machinery classes use, so agreement is somewhat by construction — which is the point, but worth stating plainly rather than presenting as independent corroboration.

So the defensible claim for the PEP is: making ParamSpec and TypeVarTuple subscription follow the existing class rules is implementable by reusing existing machinery, and produces consistent results across every form in the spec. That’s enough to justify the PEP simply saying “same rules as generic classes” rather than restating them.

2 Likes

Nice, thank you!

My feeling is that a generic function with syntactically explicit type variables should still be treated as generic, with all of its typevars, even if they are unused. This matches the runtime introspection, and I think gives the least confusing results in the face of the new ability to explicitly subscript functions. So this would be an area where mypy would be treated as out-of-conformance.

The kinds-of-Callable issue remains the most thorny for me. It is true that there are existing unsoundnesses in the spec, but that doesn’t make me eager to add more of them.

This seems like an unpleasant footgun to leave laying around. You have some code that explicitly subscripts a function (maybe from a third-party dependency), later an upgrade to that dependency changes the function to a partial (maybe even only in certain runtime codepaths, so a quick smoke test with the new dependency version seems fine), and now your code blows up with a runtime exception in production.

I think what seems especially unpleasant about this is that it’s not just “the type checker did not catch a runtime exception”. It’s “a code change that exists only for the sake of static type checking itself broke runtime, and the static type checker can’t help you avoid that.”

So I have to say that while I don’t love blocking this PEP on the more-kinds-of-callables discussion (which hasn’t even reached PEP stage yet, and may end up itself blocked on intersections), I also remain hesitant to move forward on PEP 718 with this hole in it.

Other than that issue, I’m satisfied with your/Fable’s take so far, and would be happy to see an updated draft of the PEP text addressing my comments.

This is the part that concerns me the most about it, but I also think it’s a fixable problem without needing to solve the more general “kinds of callables” problem by making partial and lrucache (and similar) handle this, as well as ensuring that existing machinery and functions for this handle it (would also look at functools.wraps).

Which to me, would probably look like all functions being subscriptable at runtime, retaining enough context for the various machinery to be introspected, and leaving any enforcement to a mix of static analysis tools, and tools that operate on introspection at runtime.

For some additional context on making various introspection play nicely with cases like decorators, there’s currently some ugly things I’m doing to make inspecting wrapped transforming decorators work as expected with runtime introspection. This case is for generating an inspectable signature, but it’s sort of in the same vein. We can probably do something to make it easier for people to write decorators, partial applications, and other functions that wrap the original function while remaining compatible.

I was hoping you’d say that. The branch now has Fable’s fix, conditional under the same feature flag.


Regarding the more serious problem of runtime crashes in e.g. functools.partial and lru_cache, Mike H suggests that we could just fix these and other specific cases, like wraps, at runtime (i.e., give them a __getitem__ method that returns a types.GenericAlias instance). That still feels uncomfortable, because it doesn’t address the problem in general.

I’m going to do an experiment with the stricter version that only allows subscripting things statically known as generic function objects.

I also found an existing mypy issue for this, filed in April.

2 Likes

To be clear, the special case would be cases like wraps forwarding the __getitem__, and partial generating a new __getitem__ but that all functions would have one:

It’s fine to allow functions that weren’t defined as explicitly generic to be subscripted at runtime, if it’s not a case of something wrapped, then static analysis or introspection can warn or error when it looks at it and expects any subscript to match something else that isn’t there.

It is already the case in the runtime proof of concept implementation of PEP 718 that all functions are subscriptable; that’s not under debate, but it also doesn’t help. Any Python object with a __call__ method can inhabit a Callable type. This PEP does not propose to make all Python objects subscriptable (nor should it, IMO).

1 Like

I guess I should take more care to be clear in what I’m saying here.

I see all functions being subscribable as correct and mostly sufficient (and wasn’t implying that I believed it wasn’t already being done, just that I agree with that being the way). The primary missing link is making it easy for people to write the right thing when they use an object that isn’t a function to wrap a function, and to do that for them in the common cases.

I don’t think this pep needs to be stalled on non-function callables. Let something like functools.wraps handle forwarding getitem in the simplest case, provide something that covers common cases like partial application, even if it’s just a recipe for library authors to use, and let adoption demand drive any changes needed outside the standard library.

As long as we make doing the right thing easy enough, this shouldn’t be a major concern. Wrapping a function with something other than a function while still annotating the wrapping as returning a callable is already in the territory of “things that require special care”

1 Like

While I was at it, I had a lesser AI port @Gobot1234’s runtime changes, originally a branch of 3.13.0a0, to the main branch, slated to become 3.16. It’s here: Ported @Gobot1234's function-subscript branch from 3.13a0 to main (3.16) · gvanrossum/cpython@a373a4f · GitHub

1 Like

Nice to see this moving again, I had somewhat limited through this lasts months, but I’m ready to help again drafting the modifications following @carljm suggestions , @Jelle and the comments on this thread.

I turned all changes into draft pull requests:

mypy:

CPython:

3 Likes

Harking back a week or so…

Mypy, at least, complains on both function definitions that there should be at least one argument using T. Pyright is better, but still complains on the first definition, not on the call (not sure where you expected the error TBH). Ty complains about both definitions, but its concern is that ... returns None which is incompatible with T and T=int.

Ah I probably should’ve been more clear about that, but I was using ... in the .pyi stubs sense here.


Right now I agree that in case of f[T]() -> T type-checkers are right to complain: T is a free type-variable here, so it’s impossible to resolve it.
However, this PEP will change that, because it’d allow us to write x = f[int](), causing T to resolve to int. So now, depending on how f is called, T of f[T]() -> T can actually be determined. But as before, f() will still be invalid.

Once PEP 718 is accepted, the solvability[1] of T@f will depend on how f is called, i.e. with or without explicit type argument. So reporting the error at the call site seems like the way to go to me.


  1. not sure if that’s actually a word, but I’m sure you know what I mean ↩︎

2 Likes