Skip to content

Draft implementation for PEP 718 -- function subscription - #21880

Draft
gvanrossum wants to merge 4 commits into
python:masterfrom
gvanrossum:pep718
Draft

Draft implementation for PEP 718 -- function subscription#21880
gvanrossum wants to merge 4 commits into
python:masterfrom
gvanrossum:pep718

Conversation

@gvanrossum

@gvanrossum gvanrossum commented Aug 21, 2026

Copy link
Copy Markdown
Member

Fixes #21337

This was vibe-coded with Fable 5. I haven't carefully reviewed what it wrote, though I skimmed it and it looks reasonable; and it works. It has tests and all tests pass.

The change adds a new feature flag, --enable-incomplete-feature SubscriptableFunctions. When this is set, it supports subscription of generic callables, pretty much exactly the way it works for classes. Note that this allows some things to pass that fail at runtime, e.g. functools.partial and lru_cache. (I have a separate PR, #21881 that makes this stricter, but it has other problems that make it worse.)

PR description by Fable (sorry, the copy/paste lost the markdown) Here's the tour, in the order the type checker actually encounters the code.
  1. mypy/options.py — the flag (5 lines)

Purely bookkeeping. Adds SUBSCRIPTABLE_FUNCTIONS = "SubscriptableFunctions" to the INCOMPLETE_FEATURES frozenset, which is what makes --enable-incomplete-feature=SubscriptableFunctions a recognized value instead of a command-line error. Everything else in the patch is gated on this, so with the flag off the checker takes exactly the paths it takes on master.

  1. mypy/semanal.py — teaching semantic analysis about function subscripts (49 lines)

Semantic analysis runs first and decides how to parse the thing inside the brackets. This matters for exactly two syntaxes that are only legal in type position: bracketed ParamSpec lists (wrap[[int], str]) and Unpack/*Ts.

There was already a chain here that asks "what is the base of this subscript?" — for a TypeInfo (a class) it pulls allow_unpack, has_param_spec, and the expected arity off the class. Everything else fell to an else branch that sets all three to "no / unknown". Functions were landing in that else, which is why wrap[[int], str] was rejected as "bracketed expression is not valid as a type."

The new branch handles FuncDef and OverloadedFuncDef (unwrapping Decorator items for overloads). Two paths inside it, and the split is the interesting part:

PEP 695 functions read fd.type_args — the unanalyzed TypeParam list, each carrying a kind (TYPE_VAR_KIND / PARAM_SPEC_KIND / TYPE_VAR_TUPLE_KIND). This is the fix for the bug I hit earlier: at this point in the pass, fd.type.variables on the analyzed signature is still empty, so my first attempt at detection silently found nothing. type_args is populated straight from the parser, so it's reliable here.
Old-style functions (P = ParamSpec("P")) have no type_args at all, and their variables is also empty at this stage. There's no reliable source, so this path goes permissive — allow_unpack = True, has_param_spec = True, num_args = -1 (meaning "don't arity-check here") — and defers all validation to the checking phase. There's a pre-existing TODO right above this code saying essentially the same thing about the general case, so it's in keeping with the file.

Two things I'd flag before you ship this: the permissive fallback assigns num_args = -1 rather than accumulating, so in a mixed overload it clobbers counts from sibling items — harmless today because -1 disables the arity check anyway, but it's sloppy and would bite anyone who later tightened it. And the branch as written accepts old-style ParamSpec syntax somewhat generously; checkexpr catches the real errors, but the diagnostics for a genuinely malformed bracket may be worse than the class-object equivalent.

  1. mypy/checkexpr.py — the actual semantics (154 lines)
    Two entry points

visit_type_application is the main door. Semanal already builds a TypeApplication node for f[int] when f resolves to a FuncDef or OverloadedFuncDef — on master this exists solely so the checker can emit ONLY_CLASS_APPLICATION. The change intercepts just before that error and routes to the new code. This covers plain functions and unbound methods.

visit_index_with_type is the back door, for cases semanal can't see statically: bound methods (c.method[str]), and variables whose type is a generic Callable even though the expression isn't a function reference. Here the index was already analyzed as a value expression, so it has to be re-parsed as types — that's parse_index_as_type_arguments, which splits a TupleExpr and runs each item through try_parse_as_type_expression, the check-time expression→type machinery PEP 747 added. Returning None on any failure is deliberate: it means "this isn't a type subscript," and the caller falls through to ordinary getitem semantics, so a genuine getitem on a callable object still works.

apply_subscript_to_function — dispatch and overload filtering

For a single CallableType it delegates and converts failure to AnyType(from_error).

For an Overloaded it implements the PEP's pre-filtering: try each item with report=False, keep the ones that accept this subscription. If none match, one error; if exactly one matches, return the bare callable (so reveal_type shows a concrete signature rather than a one-item overload); otherwise return a narrowed Overloaded and let normal call-site resolution finish the job. The stock apply_type_arguments_to_callable can't be reused here because it errors if any item mismatches, which is the opposite of what the PEP specifies.

subscriptable_own_type_vars — the binding rules

The PEP says subscripting a method binds only the method's own type parameters. For instance methods this is free — binding already consumed the class's. The hard case is a classmethod on an unspecialized generic class (C.cm[str] where class C[T]), where the callable still carries both.

You can't just match namespaces: I confirmed by instrumentation that in this position the tvars come through as [('T', 'mod.C'), ('U', '')] — the method's own tvar has an empty namespace, not the method's fullname. So the test is inverted: walk the list from the right and stop at the first tvar whose namespace is a proper ancestor of the definition's fullname (that one belongs to the class). Everything to its right is the function's own. This leans on the invariant that class tvars always precede the function's — which holds in mypy, and which I'd want stated explicitly in the PEP, since it's the thing every implementation will have to rely on.

The function returns (n_skip, own_tvars); the skipped ones get None placeholders later, leaving them free for inference.

apply_subscript_to_callable_item — the per-item work

Four stages:

Genericity check. Empty type_vars → "Cannot subscript a function with no type parameters." Note this is stricter than the runtime, where f[int] on a non-generic function just succeeds and returns a types.GenericAlias.
Arity. Minimum is the count of tvars without defaults; maximum is the total. Reuses msg.incompatible_type_application so the wording matches the class-object case.
Argument shaping, which forks:
TypeVarTuple present: pack the middle args into a TupleType. This is done inline rather than via split_for_callable because that helper routes through Instance and is class-only — its own TODO anticipates variadic functions as future work. Skipping this is what produced the AssertionError crash in applytype.py I hit earlier.
Otherwise, too few args: PEP 696 default padding. The env dict is threaded through the loop and updated as each default resolves, so a default referencing an earlier parameter ([S, T = S]) expands correctly rather than leaking an unbound tvar.
Apply, prefixing n_skip Nones for class tvars and handing off to the existing apply_generic_arguments. Because it's the standard applicator, bounds and constraints get enforced for free — bounded[int] where T: str errors with mypy's normal type-var message, no new code.

The full_args: list[Type | None] two-step at the end instead of [None] * n_skip + padded is only there because list is invariant and mypy's self-check rejected the concatenation.

  1. test-data/unit/check-pep718.test (111 lines)

Eleven cases in mypy's standard format, each pinned to --python-version=3.12 for PEP 695 syntax: basics, unsolvable tvars, arity and non-generic errors, flag-off behavior, PEP 696 defaults including the [S, T = S] case, methods and classmethods, Callable-typed values through decorators, overload filtering, ParamSpec, TypeVarTuple.

@github-actions

This comment has been minimized.

@gvanrossum

gvanrossum commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Some TODOs:

  • Failing tests. Looks like some tests require the new feature flag to be set. How to do that?
  • Should only be supported for Python version >= 3.16.
  • Do we need the feature flag once the PEP is accepted?
  • Review by someone who understands mypy internals.

@github-actions

Copy link
Copy Markdown
Contributor

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add reference implementation of PEP 718

2 participants