Draft implementation for PEP 718 -- function subscription - #21880
Draft
gvanrossum wants to merge 4 commits into
Draft
Draft implementation for PEP 718 -- function subscription#21880gvanrossum wants to merge 4 commits into
gvanrossum wants to merge 4 commits into
Conversation
This comment has been minimized.
This comment has been minimized.
Member
Author
|
Some TODOs:
|
Contributor
|
According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.partialandlru_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.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.
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.
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.
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.