Skip to content

Commit 6bc619f

Browse files
Handle functools.Placeholder in partial
Fixes #21313.
1 parent 0007c52 commit 6bc619f

2 files changed

Lines changed: 123 additions & 3 deletions

File tree

mypy/plugins/functools.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,38 @@
99
import mypy.semanal
1010
from mypy.argmap import map_actuals_to_formals
1111
from mypy.erasetype import erase_typevars
12+
from mypy.expandtype import expand_type
13+
from mypy.infer import infer_type_arguments
1214
from mypy.nodes import (
1315
ARG_POS,
1416
ARG_STAR2,
1517
SYMBOL_FUNCBASE_TYPES,
1618
ArgKind,
1719
Argument,
1820
CallExpr,
21+
Expression,
22+
MemberExpr,
1923
NameExpr,
2024
Var,
2125
)
2226
from mypy.plugins.common import add_method_to_class
2327
from mypy.typeops import get_all_type_vars
2428
from mypy.types import (
29+
ANY_STRATEGY,
2530
AnyType,
31+
BoolTypeQuery,
2632
CallableType,
2733
Instance,
2834
Overloaded,
2935
ParamSpecFlavor,
3036
ParamSpecType,
3137
Type,
3238
TypeOfAny,
39+
TypeVarId,
3340
TypeVarType,
3441
UnboundType,
3542
UnionType,
43+
UnpackType,
3644
get_proper_type,
3745
)
3846

@@ -41,6 +49,7 @@
4149
_ORDERING_METHODS: Final = {"__lt__", "__le__", "__gt__", "__ge__"}
4250

4351
PARTIAL: Final = "functools.partial"
52+
PLACEHOLDER: Final = "functools.Placeholder"
4453

4554

4655
class _MethodInfo:
@@ -134,6 +143,22 @@ def _analyze_class(ctx: mypy.plugin.ClassDefContext) -> dict[str, _MethodInfo |
134143
return comparison_methods
135144

136145

146+
def _is_functools_placeholder(expr: Expression) -> bool:
147+
return isinstance(expr, (NameExpr, MemberExpr)) and expr.fullname == PLACEHOLDER
148+
149+
150+
class _HasUnpack(BoolTypeQuery):
151+
def __init__(self) -> None:
152+
super().__init__(ANY_STRATEGY)
153+
154+
def visit_unpack_type(self, t: UnpackType) -> bool:
155+
return True
156+
157+
158+
def _has_unpack(typ: Type) -> bool:
159+
return typ.accept(_HasUnpack())
160+
161+
137162
def partial_new_callback(ctx: mypy.plugin.FunctionContext) -> Type:
138163
"""Infer a more precise return type for functools.partial"""
139164
if not isinstance(ctx.api, mypy.checker.TypeChecker): # use internals
@@ -184,6 +209,7 @@ def handle_partial_with_callee(ctx: mypy.plugin.FunctionContext, callee: Type) -
184209
actual_arg_kinds = []
185210
actual_arg_names = []
186211
actual_types = []
212+
placeholder_actuals = []
187213
seen_args = set()
188214
for i, param in enumerate(ctx.args[1:], start=1):
189215
for j, a in enumerate(param):
@@ -198,6 +224,9 @@ def handle_partial_with_callee(ctx: mypy.plugin.FunctionContext, callee: Type) -
198224
actual_arg_kinds.append(ctx.arg_kinds[i][j])
199225
actual_arg_names.append(ctx.arg_names[i][j])
200226
actual_types.append(ctx.arg_types[i][j])
227+
placeholder_actuals.append(
228+
ctx.arg_kinds[i][j].is_positional() and _is_functools_placeholder(a)
229+
)
201230

202231
formal_to_actual = map_actuals_to_formals(
203232
actual_kinds=actual_arg_kinds,
@@ -215,8 +244,20 @@ def handle_partial_with_callee(ctx: mypy.plugin.FunctionContext, callee: Type) -
215244
continue
216245
can_infer_ids.update({tv.id for tv in get_all_type_vars(arg_type)})
217246

247+
defaulted_arg_types = list(fn_type.arg_types)
248+
for i, actuals in enumerate(formal_to_actual):
249+
if any(placeholder_actuals[j] for j in actuals):
250+
# functools.Placeholder is a positional sentinel introduced in Python 3.14.
251+
# It occupies the formal slot but does not bind it, so make the validation
252+
# call accept the sentinel while preserving the original type for the
253+
# resulting partial signature below.
254+
defaulted_arg_types[i] = actual_types[
255+
next(j for j in actuals if placeholder_actuals[j])
256+
]
257+
218258
# special_sig="partial" allows omission of args/kwargs typed with ParamSpec
219259
defaulted = fn_type.copy_modified(
260+
arg_types=defaulted_arg_types,
220261
arg_kinds=[
221262
(
222263
ArgKind.ARG_OPT
@@ -273,10 +314,30 @@ def handle_partial_with_callee(ctx: mypy.plugin.FunctionContext, callee: Type) -
273314
partial_kinds = []
274315
partial_types = []
275316
partial_names = []
317+
inferred_type_vars: dict[TypeVarId, Type] = {}
318+
if any(placeholder_actuals) and len(bound.arg_types) == len(fn_type.arg_types):
319+
for i, actuals in enumerate(formal_to_actual):
320+
if not actuals or any(placeholder_actuals[j] for j in actuals):
321+
continue
322+
if _has_unpack(fn_type.arg_types[i]) or _has_unpack(bound.arg_types[i]):
323+
# TypeVarTuple/Unpack constraints are handled by check_call() above. Calling
324+
# infer_type_arguments() directly on an UnpackType trips the constraint builder's
325+
# internal "unpack should be handled at a higher level" guard.
326+
continue
327+
inferred_args = infer_type_arguments(
328+
fn_type.variables, fn_type.arg_types[i], bound.arg_types[i]
329+
)
330+
for type_var, inferred_arg in zip(fn_type.variables, inferred_args):
331+
if inferred_arg is not None and mypy.checker.is_valid_inferred_type(
332+
inferred_arg, ctx.api.options
333+
):
334+
inferred_type_vars[type_var.id] = inferred_arg
276335
# We need to fully apply any positional arguments (they cannot be respecified)
277336
# However, keyword arguments can be respecified, so just give them a default
278337
for i, actuals in enumerate(formal_to_actual):
279-
if len(bound.arg_types) == len(fn_type.arg_types):
338+
if any(placeholder_actuals[j] for j in actuals):
339+
arg_type = expand_type(fn_type.arg_types[i], inferred_type_vars)
340+
elif len(bound.arg_types) == len(fn_type.arg_types):
280341
arg_type = bound.arg_types[i]
281342
if not mypy.checker.is_valid_inferred_type(arg_type, ctx.api.options):
282343
arg_type = fn_type.arg_types[i] # bit of a hack
@@ -285,10 +346,16 @@ def handle_partial_with_callee(ctx: mypy.plugin.FunctionContext, callee: Type) -
285346
# true when PEP 646 things are happening. See testFunctoolsPartialTypeVarTuple
286347
arg_type = fn_type.arg_types[i]
287348

288-
if not actuals or fn_type.arg_kinds[i] in (ArgKind.ARG_STAR, ArgKind.ARG_STAR2):
349+
if (
350+
not actuals
351+
or fn_type.arg_kinds[i] in (ArgKind.ARG_STAR, ArgKind.ARG_STAR2)
352+
or any(placeholder_actuals[j] for j in actuals)
353+
):
289354
partial_kinds.append(fn_type.arg_kinds[i])
290355
partial_types.append(arg_type)
291-
partial_names.append(fn_type.arg_names[i])
356+
partial_names.append(
357+
None if any(placeholder_actuals[j] for j in actuals) else fn_type.arg_names[i]
358+
)
292359
else:
293360
assert actuals
294361
if any(actual_arg_kinds[j] in (ArgKind.ARG_POS, ArgKind.ARG_STAR) for j in actuals):

test-data/unit/check-functools.test

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,9 @@ def bar(a: int, b: str, c: float) -> None: ...
333333
p(bar, 1, "a", 3.0) # OK
334334
p(bar, 1, "a", 3.0, kwarg="asdf") # OK
335335
p(bar, 1, "a", "b") # E: Argument 1 to "foo" has incompatible type "Callable[[int, str, float], None]"; expected "Callable[[int, str, str], None]"
336+
p2 = functools.partial(foo, bar, 1) # E: Argument 1 to "foo" has incompatible type "Callable[[int, str, float], None]"; expected "Callable[[int], None]"
337+
p2("a", 3.0, kwarg="asdf") # E: Argument 1 to "foo" has incompatible type "str"; expected "int" \
338+
# E: Argument 2 to "foo" has incompatible type "float"; expected "int"
336339
[builtins fixtures/dict.pyi]
337340

338341
[case testFunctoolsPartialUnion]
@@ -726,3 +729,53 @@ def outer_c(arg: Tc) -> None:
726729
use_int_callable(partial(inner, b="")) # E: Argument 1 to "use_int_callable" has incompatible type "partial[str]"; expected "Callable[[int], int]" \
727730
# N: "partial[str].__call__" has type "def __call__(__self, *args: Any, **kwargs: Any) -> str"
728731
[builtins fixtures/tuple.pyi]
732+
733+
[case testFunctoolsPartialPlaceholder]
734+
import functools
735+
from functools import partial, Placeholder as _
736+
from typing import TypeVar
737+
738+
T = TypeVar("T")
739+
740+
741+
def foo(a: int, b: str, c: bool) -> tuple[int, str, bool]: ...
742+
743+
744+
p = partial(foo, _, "x", _)
745+
reveal_type(p) # N: Revealed type is "functools.partial[tuple[builtins.int, builtins.str, builtins.bool]]"
746+
reveal_type(p(1, True)) # N: Revealed type is "tuple[builtins.int, builtins.str, builtins.bool]"
747+
p("bad", True) # E: Argument 1 to "foo" has incompatible type "str"; expected "int"
748+
p(1, 1) # E: Argument 2 to "foo" has incompatible type "int"; expected "bool"
749+
p(a=1, c=True) # E: Unexpected keyword argument "a" for "foo" \
750+
# E: Unexpected keyword argument "c" for "foo"
751+
752+
753+
def same(a: T, b: T) -> T: ...
754+
def same_list(a: T, b: list[T]) -> T: ...
755+
756+
757+
generic = partial(same, _, 1)
758+
reveal_type(generic) # N: Revealed type is "functools.partial[builtins.int]"
759+
generic(2)
760+
generic("bad") # E: Argument 1 to "same" has incompatible type "str"; expected "int"
761+
762+
nested_generic = partial(same_list, _, [1])
763+
reveal_type(nested_generic) # N: Revealed type is "functools.partial[builtins.int]"
764+
nested_generic(2)
765+
nested_generic("bad") # E: Argument 1 to "same_list" has incompatible type "str"; expected "int"
766+
767+
module_attr = partial(foo, functools.Placeholder, "x", functools.Placeholder)
768+
reveal_type(module_attr(1, True)) # N: Revealed type is "tuple[builtins.int, builtins.str, builtins.bool]"
769+
partial(foo, a=_) # E: Argument "a" to "foo" has incompatible type "_PlaceholderType"; expected "int"
770+
[file functools.pyi]
771+
from typing import Any, Callable, Final, Generic, TypeVar
772+
773+
_T = TypeVar("_T")
774+
775+
class _PlaceholderType: ...
776+
Placeholder: Final[_PlaceholderType]
777+
778+
class partial(Generic[_T]):
779+
def __new__(cls, func: Callable[..., _T], /, *args: Any, **kwargs: Any) -> partial[_T]: ...
780+
def __call__(self, *args: Any, **kwargs: Any) -> _T: ...
781+
[builtins fixtures/tuple.pyi]

0 commit comments

Comments
 (0)