diff --git a/Lib/functools.py b/Lib/functools.py index 8425f6030010f3d..72cd829190c154a 100644 --- a/Lib/functools.py +++ b/Lib/functools.py @@ -315,52 +315,6 @@ def _partial_prepare_merger(args): merger = itemgetter(*order) if phcount else None return phcount, merger -def _partial_new(cls, func, /, *args, **keywords): - if issubclass(cls, partial): - base_cls = partial - if not callable(func): - raise TypeError("the first argument must be callable") - else: - base_cls = partialmethod - # func could be a descriptor like classmethod which isn't callable - if not callable(func) and not hasattr(func, "__get__"): - raise TypeError(f"the first argument {func!r} must be a callable " - "or a descriptor") - if args and args[-1] is Placeholder: - raise TypeError("trailing Placeholders are not allowed") - for value in keywords.values(): - if value is Placeholder: - raise TypeError("Placeholder cannot be passed as a keyword argument") - if isinstance(func, base_cls): - pto_phcount = func._phcount - tot_args = func.args - if args: - tot_args += args - if pto_phcount: - # merge args with args of `func` which is `partial` - nargs = len(args) - if nargs < pto_phcount: - tot_args += (Placeholder,) * (pto_phcount - nargs) - tot_args = func._merger(tot_args) - if nargs > pto_phcount: - tot_args += args[pto_phcount:] - phcount, merger = _partial_prepare_merger(tot_args) - else: # works for both pto_phcount == 0 and != 0 - phcount, merger = pto_phcount, func._merger - keywords = {**func.keywords, **keywords} - func = func.func - else: - tot_args = args - phcount, merger = _partial_prepare_merger(tot_args) - - self = object.__new__(cls) - self.func = func - self.args = tot_args - self.keywords = keywords - self._phcount = phcount - self._merger = merger - return self - def _partial_repr(self): cls = type(self) module = cls.__module__ @@ -379,7 +333,44 @@ class partial: __slots__ = ("func", "args", "keywords", "_phcount", "_merger", "__dict__", "__weakref__") - __new__ = _partial_new + def __new__(cls, func, /, *args, **keywords): + if not callable(func): + raise TypeError("the first argument must be callable") + if args and args[-1] is Placeholder: + raise TypeError("trailing Placeholders are not allowed") + for value in keywords.values(): + if value is Placeholder: + raise TypeError("Placeholder cannot be passed as a keyword argument") + if isinstance(func, partial): + pto_phcount = func._phcount + tot_args = func.args + if args: + tot_args += args + if pto_phcount: + # merge args with args of `func` which is `partial` + nargs = len(args) + if nargs < pto_phcount: + tot_args += (Placeholder,) * (pto_phcount - nargs) + tot_args = func._merger(tot_args) + if nargs > pto_phcount: + tot_args += args[pto_phcount:] + phcount, merger = _partial_prepare_merger(tot_args) + else: # works for both pto_phcount == 0 and != 0 + phcount, merger = pto_phcount, func._merger + keywords = {**func.keywords, **keywords} + func = func.func + else: + tot_args = args + phcount, merger = _partial_prepare_merger(tot_args) + + self = object.__new__(cls) + self.func = func + self.args = tot_args + self.keywords = keywords + self._phcount = phcount + self._merger = merger + return self + __repr__ = recursive_repr()(_partial_repr) def __call__(self, /, *args, **keywords): @@ -444,6 +435,7 @@ def __setstate__(self, state): except ImportError: pass + # Descriptor version class partialmethod: """Method descriptor with partial application of the given arguments @@ -452,27 +444,45 @@ class partialmethod: Supports wrapping existing descriptors and handles non-descriptor callables as instance methods. """ - __new__ = _partial_new + __slots__ = ("func", "args", "keywords", "__dict__", "__weakref__") + __repr__ = _partial_repr + def __init__(self, func, /, *args, **keywords): + if isinstance(func, partialmethod): + # Subclass optimization + temp = partial(lambda *_, **__: None, *func.args, **func.keywords) + temp = partial(temp, *args, **keywords) + func = func.func + args = temp.args + keywords = temp.keywords + else: + if not callable(func) and not hasattr(func, "__get__"): + raise TypeError(f"the first argument {func!r} must be a callable " + "or a descriptor") + if args and args[-1] is Placeholder: + raise TypeError("trailing Placeholders are not allowed") + for value in keywords.values(): + if value is Placeholder: + raise TypeError("Placeholder cannot be passed as a keyword argument") + + self.func = func + self.args = args + self.keywords = keywords + def _make_unbound_method(self): - def _method(cls_or_self, /, *args, **keywords): - phcount = self._phcount - if phcount: - try: - pto_args = self._merger(self.args + args) - args = args[phcount:] - except IndexError: - raise TypeError("missing positional arguments " - "in 'partialmethod' call; expected " - f"at least {phcount}, got {len(args)}") - else: - pto_args = self.args - keywords = {**self.keywords, **keywords} - return self.func(cls_or_self, *pto_args, *args, **keywords) - _method.__isabstractmethod__ = self.__isabstractmethod__ - _method.__partialmethod__ = self - return _method + func = self.func + args = self.args + if not callable(func): + def func(*args, **kwds): + return self.func(*args, **kwds) + if args: + method = partial(func, Placeholder, *args, **self.keywords) + else: + method = partial(func, **self.keywords) + method.__isabstractmethod__ = self.__isabstractmethod__ + method.__partialmethod__ = self + return method def __get__(self, obj, cls=None): get = getattr(self.func, "__get__", None) @@ -511,13 +521,14 @@ def _unwrap_partialmethod(func): prev = None while func is not prev: prev = func - while isinstance(getattr(func, "__partialmethod__", None), partialmethod): - func = func.__partialmethod__ - while isinstance(func, partialmethod): - func = getattr(func, 'func') - func = _unwrap_partial(func) + __partialmethod__ = getattr(func, "__partialmethod__", None) + if isinstance(__partialmethod__, partialmethod): + func = __partialmethod__.func + if isinstance(func, (partial, partialmethod)): + func = func.func return func + ################################################################################ ### LRU Cache function decorator ################################################################################ diff --git a/Lib/inspect.py b/Lib/inspect.py index c52469e63861a22..d4ff4a0ffd2c53b 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -2492,39 +2492,6 @@ def _signature_from_callable(obj, *, 'attribute'.format(sig)) return sig - try: - partialmethod = obj.__partialmethod__ - except AttributeError: - pass - else: - if isinstance(partialmethod, functools.partialmethod): - # Unbound partialmethod (see functools.partialmethod) - # This means, that we need to calculate the signature - # as if it's a regular partial object, but taking into - # account that the first positional argument - # (usually `self`, or `cls`) will not be passed - # automatically (as for boundmethods) - - wrapped_sig = _get_signature_of(partialmethod.func) - - sig = _signature_get_partial(wrapped_sig, partialmethod, (None,)) - first_wrapped_param = tuple(wrapped_sig.parameters.values())[0] - if first_wrapped_param.kind is Parameter.VAR_POSITIONAL: - # First argument of the wrapped callable is `*args`, as in - # `partialmethod(lambda *args)`. - return sig - else: - sig_params = tuple(sig.parameters.values()) - assert (not sig_params or - first_wrapped_param is not sig_params[0]) - # If there were placeholders set, - # first param is transformed to positional only - if partialmethod.args.count(functools.Placeholder): - first_wrapped_param = first_wrapped_param.replace( - kind=Parameter.POSITIONAL_ONLY) - new_params = (first_wrapped_param,) + sig_params - return sig.replace(parameters=new_params) - if isinstance(obj, functools.partial): wrapped_sig = _get_signature_of(obj.func) return _signature_get_partial(wrapped_sig, obj) diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py index b46a1ce6d634a5f..370151556ebbc78 100644 --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -860,6 +860,60 @@ class PartialMethodSubclass(functools.partialmethod): self.assertIs(p2.func, min) self.assertEqual(p2.__get__(0)(), 0) + def test_regression(self): + # 1. Placeholder TypeError on Initialization + with self.assertRaises(TypeError): + class C: + m = functools.partialmethod(print, functools.Placeholder) + + # 2. A descriptor that needs no binding + class D: + def __get__(self, obj, cls=None): return self + def __call__(self, *a, **kw): return a + class C: + m = functools.partialmethod(D(), 42) + c = C() + self.assertEqual(c.m(), (c, 42)) + + # 3. Correct coroutine detection + async def coro(self, a): ... + + class B: + pm = functools.partialmethod(coro, 1) + ps = functools.partialmethod(staticmethod(coro), 1) + pc = functools.partialmethod(classmethod(coro), 1) + + for name in ("pm", "ps", "pc"): + self.assertTrue(getattr(B, name)) + self.assertTrue(getattr(B(), name)) + + # 4. Bound method pickling + bound = _SerializationTestDescriptor().pm + deserialized_bound = pickle.loads(pickle.dumps(bound)) + self.assertEqual(deserialized_bound(9), (1, 9)) + + # 5. Not callable itself; returns + # a) self on class access + # b) a callable on instance access. + class Desc: + def __get__(self, obj, cls=None): + if obj is None: return self + return functools.partial(lambda o, a, b: (a, b), obj) + + class A: + pd = functools.partialmethod(Desc(), 1) + + self.assertEqual(A().pd(2), (1, 2)) + A.pd # Returns without error + with self.assertRaises(TypeError): + A.pd(1) # Only raises on call + + +class _SerializationTestDescriptor: + # Pickling fails for both local and class level definitions + def instance_method(self, x, y): return (x, y) + pm = functools.partialmethod(instance_method, 1) + class TestUpdateWrapper(unittest.TestCase): diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index df5843abfcb8753..2056f4200257075 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -3913,8 +3913,10 @@ def test(): pass ham = partialmethod(test) - with self.assertRaisesRegex(ValueError, "has incorrect arguments"): - inspect.signature(Spam.ham) + self.assertEqual(self.signature(Spam.ham, eval_str=False), + ((), Ellipsis)) + with self.assertRaisesRegex(ValueError, "invalid method signature"): + inspect.signature(Spam().ham) class Spam: def test(it, a, b, *, c) -> 'spam': @@ -3953,7 +3955,7 @@ def test(self: 'anno', x): g = partialmethod(test, 1) self.assertEqual(self.signature(Spam.g, eval_str=False), - ((('self', ..., 'anno', 'positional_or_keyword'),), + ((('self', ..., 'anno', 'positional_only'),), ...)) def test_signature_on_fake_partialmethod(self):