]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-101293: Fix support of custom callables and types in inspect.Signature.from_callab...
authorSerhiy Storchaka <storchaka@gmail.com>
Fri, 1 Mar 2024 11:32:16 +0000 (13:32 +0200)
committerGitHub <noreply@github.com>
Fri, 1 Mar 2024 11:32:16 +0000 (13:32 +0200)
Support callables with the __call__() method and types with
__new__() and __init__() methods set to class methods, static
methods, bound methods, partial functions, and other types of
methods and descriptors.

Add tests for numerous types of callables and descriptors.

Lib/inspect.py
Lib/test/test_inspect/test_inspect.py
Misc/NEWS.d/next/Library/2024-02-15-19-11-49.gh-issue-101293.898b8l.rst [new file with mode: 0644]

index 9191d4761b2c76875c9d189ed090ab86a953e390..8a2b2c96e993b5f0035a19bc6bdd546f462bf86a 100644 (file)
@@ -2039,15 +2039,17 @@ def _signature_get_user_defined_method(cls, method_name):
     named ``method_name`` and returns it only if it is a
     pure python function.
     """
-    try:
-        meth = getattr(cls, method_name)
-    except AttributeError:
-        return
+    if method_name == '__new__':
+        meth = getattr(cls, method_name, None)
     else:
-        if not isinstance(meth, _NonUserDefinedCallables):
-            # Once '__signature__' will be added to 'C'-level
-            # callables, this check won't be necessary
-            return meth
+        meth = getattr_static(cls, method_name, None)
+    if meth is None or isinstance(meth, _NonUserDefinedCallables):
+        # Once '__signature__' will be added to 'C'-level
+        # callables, this check won't be necessary
+        return None
+    if method_name != '__new__':
+        meth = _descriptor_get(meth, cls)
+    return meth
 
 
 def _signature_get_partial(wrapped_sig, partial, extra_args=()):
@@ -2492,6 +2494,15 @@ def _signature_from_function(cls, func, skip_bound_arg=True,
                __validate_parameters__=is_duck_function)
 
 
+def _descriptor_get(descriptor, obj):
+    if isclass(descriptor):
+        return descriptor
+    get = getattr(type(descriptor), '__get__', _sentinel)
+    if get is _sentinel:
+        return descriptor
+    return get(descriptor, obj, type(obj))
+
+
 def _signature_from_callable(obj, *,
                              follow_wrapper_chains=True,
                              skip_bound_arg=True,
@@ -2600,7 +2611,6 @@ def _signature_from_callable(obj, *,
         wrapped_sig = _get_signature_of(obj.func)
         return _signature_get_partial(wrapped_sig, obj)
 
-    sig = None
     if isinstance(obj, type):
         # obj is a class or a metaclass
 
@@ -2608,88 +2618,65 @@ def _signature_from_callable(obj, *,
         # in its metaclass
         call = _signature_get_user_defined_method(type(obj), '__call__')
         if call is not None:
-            sig = _get_signature_of(call)
-        else:
-            factory_method = None
-            new = _signature_get_user_defined_method(obj, '__new__')
-            init = _signature_get_user_defined_method(obj, '__init__')
-
-            # Go through the MRO and see if any class has user-defined
-            # pure Python __new__ or __init__ method
-            for base in obj.__mro__:
-                # Now we check if the 'obj' class has an own '__new__' method
-                if new is not None and '__new__' in base.__dict__:
-                    factory_method = new
-                    break
-                # or an own '__init__' method
-                elif init is not None and '__init__' in base.__dict__:
-                    factory_method = init
-                    break
+            return _get_signature_of(call)
 
-            if factory_method is not None:
-                sig = _get_signature_of(factory_method)
-
-        if sig is None:
-            # At this point we know, that `obj` is a class, with no user-
-            # defined '__init__', '__new__', or class-level '__call__'
-
-            for base in obj.__mro__[:-1]:
-                # Since '__text_signature__' is implemented as a
-                # descriptor that extracts text signature from the
-                # class docstring, if 'obj' is derived from a builtin
-                # class, its own '__text_signature__' may be 'None'.
-                # Therefore, we go through the MRO (except the last
-                # class in there, which is 'object') to find the first
-                # class with non-empty text signature.
-                try:
-                    text_sig = base.__text_signature__
-                except AttributeError:
-                    pass
-                else:
-                    if text_sig:
-                        # If 'base' class has a __text_signature__ attribute:
-                        # return a signature based on it
-                        return _signature_fromstr(sigcls, base, text_sig)
-
-            # No '__text_signature__' was found for the 'obj' class.
-            # Last option is to check if its '__init__' is
-            # object.__init__ or type.__init__.
-            if type not in obj.__mro__:
-                # We have a class (not metaclass), but no user-defined
-                # __init__ or __new__ for it
-                if (obj.__init__ is object.__init__ and
-                    obj.__new__ is object.__new__):
-                    # Return a signature of 'object' builtin.
-                    return sigcls.from_callable(object)
-                else:
-                    raise ValueError(
-                        'no signature found for builtin type {!r}'.format(obj))
+        new = _signature_get_user_defined_method(obj, '__new__')
+        init = _signature_get_user_defined_method(obj, '__init__')
 
-    elif not isinstance(obj, _NonUserDefinedCallables):
-        # An object with __call__
-        # We also check that the 'obj' is not an instance of
-        # types.WrapperDescriptorType or types.MethodWrapperType to avoid
-        # infinite recursion (and even potential segfault)
-        call = _signature_get_user_defined_method(type(obj), '__call__')
-        if call is not None:
+        # Go through the MRO and see if any class has user-defined
+        # pure Python __new__ or __init__ method
+        for base in obj.__mro__:
+            # Now we check if the 'obj' class has an own '__new__' method
+            if new is not None and '__new__' in base.__dict__:
+                sig = _get_signature_of(new)
+                if skip_bound_arg:
+                    sig = _signature_bound_method(sig)
+                return sig
+            # or an own '__init__' method
+            elif init is not None and '__init__' in base.__dict__:
+                return _get_signature_of(init)
+
+        # At this point we know, that `obj` is a class, with no user-
+        # defined '__init__', '__new__', or class-level '__call__'
+
+        for base in obj.__mro__[:-1]:
+            # Since '__text_signature__' is implemented as a
+            # descriptor that extracts text signature from the
+            # class docstring, if 'obj' is derived from a builtin
+            # class, its own '__text_signature__' may be 'None'.
+            # Therefore, we go through the MRO (except the last
+            # class in there, which is 'object') to find the first
+            # class with non-empty text signature.
             try:
-                sig = _get_signature_of(call)
-            except ValueError as ex:
-                msg = 'no signature found for {!r}'.format(obj)
-                raise ValueError(msg) from ex
-
-    if sig is not None:
-        # For classes and objects we skip the first parameter of their
-        # __call__, __new__, or __init__ methods
-        if skip_bound_arg:
-            return _signature_bound_method(sig)
-        else:
-            return sig
+                text_sig = base.__text_signature__
+            except AttributeError:
+                pass
+            else:
+                if text_sig:
+                    # If 'base' class has a __text_signature__ attribute:
+                    # return a signature based on it
+                    return _signature_fromstr(sigcls, base, text_sig)
+
+        # No '__text_signature__' was found for the 'obj' class.
+        # Last option is to check if its '__init__' is
+        # object.__init__ or type.__init__.
+        if type not in obj.__mro__:
+            # We have a class (not metaclass), but no user-defined
+            # __init__ or __new__ for it
+            if (obj.__init__ is object.__init__ and
+                obj.__new__ is object.__new__):
+                # Return a signature of 'object' builtin.
+                return sigcls.from_callable(object)
+            else:
+                raise ValueError(
+                    'no signature found for builtin type {!r}'.format(obj))
 
-    if isinstance(obj, types.BuiltinFunctionType):
-        # Raise a nicer error message for builtins
-        msg = 'no signature found for builtin function {!r}'.format(obj)
-        raise ValueError(msg)
+    else:
+        # An object with __call__
+        call = getattr_static(type(obj), '__call__', None)
+        if call is not None:
+            call = _descriptor_get(call, obj)
+            return _get_signature_of(call)
 
     raise ValueError('callable {!r} is not supported by signature'.format(obj))
 
index 9dc37852ec205a6d1340b97f16cfb8ab40fa309a..52cf68b93b85fa85b850fc1b98b1ea8cb020643d 100644 (file)
@@ -2928,9 +2928,12 @@ class TestSignatureObject(unittest.TestCase):
 
         # This doesn't work now.
         # (We don't have a valid signature for "type" in 3.4)
+        class ThisWorksNow:
+            __call__ = type
+        # TODO: Support type.
+        self.assertEqual(ThisWorksNow()(1), int)
+        self.assertEqual(ThisWorksNow()('A', (), {}).__name__, 'A')
         with self.assertRaisesRegex(ValueError, "no signature found"):
-            class ThisWorksNow:
-                __call__ = type
             test_callable(ThisWorksNow())
 
         # Regression test for issue #20786
@@ -3521,6 +3524,98 @@ class TestSignatureObject(unittest.TestCase):
                          ((('a', ..., ..., "positional_or_keyword"),),
                           ...))
 
+        with self.subTest('classmethod'):
+            class CM(type):
+                @classmethod
+                def __call__(cls, a):
+                    return a
+            class C(metaclass=CM):
+                def __init__(self, b):
+                    pass
+
+            self.assertEqual(C(1), 1)
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('staticmethod'):
+            class CM(type):
+                @staticmethod
+                def __call__(a):
+                    return a
+            class C(metaclass=CM):
+                def __init__(self, b):
+                    pass
+
+            self.assertEqual(C(1), 1)
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('MethodType'):
+            class A:
+                def call(self, a):
+                    return a
+            class CM(type):
+                __call__ = A().call
+            class C(metaclass=CM):
+                def __init__(self, b):
+                    pass
+
+            self.assertEqual(C(1), 1)
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('partial'):
+            class CM(type):
+                __call__ = functools.partial(lambda x, a: (x, a), 2)
+            class C(metaclass=CM):
+                def __init__(self, b):
+                    pass
+
+            self.assertEqual(C(1), (2, 1))
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('partialmethod'):
+            class CM(type):
+                __call__ = functools.partialmethod(lambda self, x, a: (x, a), 2)
+            class C(metaclass=CM):
+                def __init__(self, b):
+                    pass
+
+            self.assertEqual(C(1), (2, 1))
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('BuiltinMethodType'):
+            class CM(type):
+                __call__ = ':'.join
+            class C(metaclass=CM):
+                def __init__(self, b):
+                    pass
+
+            self.assertEqual(C(['a', 'bc']), 'a:bc')
+            # BUG: Returns '<Signature (b)>'
+            with self.assertRaises(AssertionError):
+                self.assertEqual(self.signature(C), self.signature(''.join))
+
+        with self.subTest('MethodWrapperType'):
+            class CM(type):
+                __call__ = (2).__pow__
+            class C(metaclass=CM):
+                def __init__(self, b):
+                    pass
+
+            self.assertEqual(C(3), 8)
+            self.assertEqual(C(3, 7), 1)
+            # BUG: Returns '<Signature (b)>'
+            with self.assertRaises(AssertionError):
+                self.assertEqual(self.signature(C), self.signature((0).__pow__))
+
         class CM(type):
             def __new__(mcls, name, bases, dct, *, foo=1):
                 return super().__new__(mcls, name, bases, dct)
@@ -3582,6 +3677,169 @@ class TestSignatureObject(unittest.TestCase):
                            ('bar', 2, ..., "keyword_only")),
                           ...))
 
+    def test_signature_on_class_with_init(self):
+        class C:
+            def __init__(self, b):
+                pass
+
+        C(1)  # does not raise
+        self.assertEqual(self.signature(C),
+                        ((('b', ..., ..., "positional_or_keyword"),),
+                        ...))
+
+        with self.subTest('classmethod'):
+            class C:
+                @classmethod
+                def __init__(cls, b):
+                    pass
+
+            C(1)  # does not raise
+            self.assertEqual(self.signature(C),
+                            ((('b', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('staticmethod'):
+            class C:
+                @staticmethod
+                def __init__(b):
+                    pass
+
+            C(1)  # does not raise
+            self.assertEqual(self.signature(C),
+                            ((('b', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('MethodType'):
+            class A:
+                def call(self, a):
+                    pass
+            class C:
+                __init__ = A().call
+
+            C(1)  # does not raise
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('partial'):
+            class C:
+                __init__ = functools.partial(lambda x, a: None, 2)
+
+            C(1)  # does not raise
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('partialmethod'):
+            class C:
+                def _init(self, x, a):
+                    self.a = (x, a)
+                __init__ = functools.partialmethod(_init, 2)
+
+            self.assertEqual(C(1).a, (2, 1))
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+    def test_signature_on_class_with_new(self):
+        with self.subTest('FunctionType'):
+            class C:
+                def __new__(cls, a):
+                    return a
+
+            self.assertEqual(C(1), 1)
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('classmethod'):
+            class C:
+                @classmethod
+                def __new__(cls, cls2, a):
+                    return a
+
+            self.assertEqual(C(1), 1)
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('staticmethod'):
+            class C:
+                @staticmethod
+                def __new__(cls, a):
+                    return a
+
+            self.assertEqual(C(1), 1)
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('MethodType'):
+            class A:
+                def call(self, cls, a):
+                    return a
+            class C:
+                __new__ = A().call
+
+            self.assertEqual(C(1), 1)
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('partial'):
+            class C:
+                __new__ = functools.partial(lambda x, cls, a: (x, a), 2)
+
+            self.assertEqual(C(1), (2, 1))
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('partialmethod'):
+            class C:
+                __new__ = functools.partialmethod(lambda cls, x, a: (x, a), 2)
+
+            self.assertEqual(C(1), (2, 1))
+            self.assertEqual(self.signature(C),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('BuiltinMethodType'):
+            class C:
+                __new__ = str.__subclasscheck__
+
+            self.assertEqual(C(), False)
+            # TODO: Support BuiltinMethodType
+            # self.assertEqual(self.signature(C), ((), ...))
+            self.assertRaises(ValueError, self.signature, C)
+
+        with self.subTest('MethodWrapperType'):
+            class C:
+                __new__ = type.__or__.__get__(int, type)
+
+            self.assertEqual(C(), C | int)
+            # TODO: Support MethodWrapperType
+            # self.assertEqual(self.signature(C), ((), ...))
+            self.assertRaises(ValueError, self.signature, C)
+
+        # TODO: Test ClassMethodDescriptorType
+
+        with self.subTest('MethodDescriptorType'):
+            class C:
+                __new__ = type.__dict__['__subclasscheck__']
+
+            self.assertEqual(C(C), True)
+            self.assertEqual(self.signature(C), self.signature(C.__subclasscheck__))
+
+        with self.subTest('WrapperDescriptorType'):
+            class C:
+                __new__ = type.__or__
+
+            self.assertEqual(C(int), C | int)
+            # TODO: Support WrapperDescriptorType
+            # self.assertEqual(self.signature(C), self.signature(C.__or__))
+            self.assertRaises(ValueError, self.signature, C)
+
     def test_signature_on_subclass(self):
         class A:
             def __new__(cls, a=1, *args, **kwargs):
@@ -3635,8 +3893,11 @@ class TestSignatureObject(unittest.TestCase):
         # Test meta-classes without user-defined __init__ or __new__
         class C(type): pass
         class D(C): pass
+        self.assertEqual(C('A', (), {}).__name__, 'A')
+        # TODO: Support type.
         with self.assertRaisesRegex(ValueError, "callable.*is not supported"):
             self.assertEqual(inspect.signature(C), None)
+        self.assertEqual(D('A', (), {}).__name__, 'A')
         with self.assertRaisesRegex(ValueError, "callable.*is not supported"):
             self.assertEqual(inspect.signature(D), None)
 
@@ -3686,6 +3947,103 @@ class TestSignatureObject(unittest.TestCase):
                          ((('a', ..., ..., "positional_or_keyword"),),
                           ...))
 
+        with self.subTest('classmethod'):
+            class C:
+                @classmethod
+                def __call__(cls, a):
+                    pass
+
+            self.assertEqual(self.signature(C()),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('staticmethod'):
+            class C:
+                @staticmethod
+                def __call__(a):
+                    pass
+
+            self.assertEqual(self.signature(C()),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('MethodType'):
+            class A:
+                def call(self, a):
+                    return a
+            class C:
+                __call__ = A().call
+
+            self.assertEqual(C()(1), 1)
+            self.assertEqual(self.signature(C()),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('partial'):
+            class C:
+                __call__ = functools.partial(lambda x, a: (x, a), 2)
+
+            self.assertEqual(C()(1), (2, 1))
+            self.assertEqual(self.signature(C()),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('partialmethod'):
+            class C:
+                __call__ = functools.partialmethod(lambda self, x, a: (x, a), 2)
+
+            self.assertEqual(C()(1), (2, 1))
+            self.assertEqual(self.signature(C()),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
+        with self.subTest('BuiltinMethodType'):
+            class C:
+                __call__ = ':'.join
+
+            self.assertEqual(C()(['a', 'bc']), 'a:bc')
+            self.assertEqual(self.signature(C()), self.signature(''.join))
+
+        with self.subTest('MethodWrapperType'):
+            class C:
+                __call__ = (2).__pow__
+
+            self.assertEqual(C()(3), 8)
+            self.assertEqual(self.signature(C()), self.signature((0).__pow__))
+
+        with self.subTest('ClassMethodDescriptorType'):
+            class C(dict):
+                __call__ = dict.__dict__['fromkeys']
+
+            res = C()([1, 2], 3)
+            self.assertEqual(res, {1: 3, 2: 3})
+            self.assertEqual(type(res), C)
+            self.assertEqual(self.signature(C()), self.signature(dict.fromkeys))
+
+        with self.subTest('MethodDescriptorType'):
+            class C(str):
+                __call__ = str.join
+
+            self.assertEqual(C(':')(['a', 'bc']), 'a:bc')
+            self.assertEqual(self.signature(C()), self.signature(''.join))
+
+        with self.subTest('WrapperDescriptorType'):
+            class C(int):
+                __call__ = int.__pow__
+
+            self.assertEqual(C(2)(3), 8)
+            self.assertEqual(self.signature(C()), self.signature((0).__pow__))
+
+        with self.subTest('MemberDescriptorType'):
+            class C:
+                __slots__ = '__call__'
+            c = C()
+            c.__call__ = lambda a: a
+            self.assertEqual(c(1), 1)
+            self.assertEqual(self.signature(c),
+                            ((('a', ..., ..., "positional_or_keyword"),),
+                            ...))
+
     def test_signature_on_wrapper(self):
         class Wrapper:
             def __call__(self, b):
diff --git a/Misc/NEWS.d/next/Library/2024-02-15-19-11-49.gh-issue-101293.898b8l.rst b/Misc/NEWS.d/next/Library/2024-02-15-19-11-49.gh-issue-101293.898b8l.rst
new file mode 100644 (file)
index 0000000..98365d2
--- /dev/null
@@ -0,0 +1,4 @@
+Support callables with the ``__call__()`` method and types with
+``__new__()`` and ``__init__()`` methods set to class methods, static
+methods, bound methods, partial functions, and other types of methods and
+descriptors in :meth:`inspect.Signature.from_callable`.