diff --git a/Lib/inspect.py b/Lib/inspect.py index fcfe3b191ab503..9592559ba6dcaa 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -1901,7 +1901,7 @@ def getasyncgenlocals(agen): types.BuiltinFunctionType) -def _signature_get_user_defined_method(cls, method_name): +def _signature_get_user_defined_method(cls, method_name, *, follow_wrapper_chains=True): """Private helper. Checks if ``cls`` has an attribute named ``method_name`` and returns it only if it is a pure python function. @@ -1910,12 +1910,20 @@ def _signature_get_user_defined_method(cls, method_name): meth = getattr(cls, method_name, None) else: meth = getattr_static(cls, method_name, None) - if meth is None or isinstance(meth, _NonUserDefinedCallables): + if meth is None: + return None + + if follow_wrapper_chains: + meth = unwrap(meth, stop=(lambda m: hasattr(m, "__signature__") + or _signature_is_builtin(m))) + if 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) + if follow_wrapper_chains: + meth = unwrap(meth, stop=lambda m: hasattr(m, "__signature__")) return meth @@ -2507,12 +2515,26 @@ def _signature_from_callable(obj, *, # First, let's see if it has an overloaded __call__ defined # in its metaclass - call = _signature_get_user_defined_method(type(obj), '__call__') + call = _signature_get_user_defined_method( + type(obj), + '__call__', + follow_wrapper_chains=follow_wrapper_chains, + ) if call is not None: return _get_signature_of(call) - new = _signature_get_user_defined_method(obj, '__new__') - init = _signature_get_user_defined_method(obj, '__init__') + # NOTE: The user-defined method can be a function with a thin wrapper + # around object.__new__ (e.g., generated by `@warnings.deprecated`) + new = _signature_get_user_defined_method( + obj, + '__new__', + follow_wrapper_chains=follow_wrapper_chains, + ) + init = _signature_get_user_defined_method( + obj, + '__init__', + follow_wrapper_chains=follow_wrapper_chains, + ) # Go through the MRO and see if any class has user-defined # pure Python __new__ or __init__ method @@ -2552,10 +2574,14 @@ def _signature_from_callable(obj, *, # Last option is to check if its '__init__' is # object.__init__ or type.__init__. if type not in obj.__mro__: + obj_init = obj.__init__ + obj_new = obj.__new__ + if follow_wrapper_chains: + obj_init = unwrap(obj_init) + obj_new = unwrap(obj_new) # 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__): + if obj_init is object.__init__ and obj_new is object.__new__: # Return a signature of 'object' builtin. return sigcls.from_callable(object) else: diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index daae990458d708..8067ddf09db016 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -3845,7 +3845,6 @@ def wrapped_foo_call(): ('b', ..., ..., "positional_or_keyword")), ...)) - def test_signature_on_class(self): class C: def __init__(self, a): @@ -4019,6 +4018,45 @@ def __init__(self, b): ('bar', 2, ..., "keyword_only")), ...)) + def test_signature_on_class_with_decorated_new(self): + def identity(func): + @functools.wraps(func) + def wrapped(*args, **kwargs): + return func(*args, **kwargs) + return wrapped + + class Foo: + @identity + def __new__(cls, a, b): + pass + + self.assertEqual(self.signature(Foo), + ((('a', ..., ..., "positional_or_keyword"), + ('b', ..., ..., "positional_or_keyword")), + ...)) + + self.assertEqual(self.signature(Foo.__new__), + ((('cls', ..., ..., "positional_or_keyword"), + ('a', ..., ..., "positional_or_keyword"), + ('b', ..., ..., "positional_or_keyword")), + ...)) + + class Bar: + __new__ = identity(object.__new__) + + varargs_signature = ( + (('args', ..., ..., 'var_positional'), + ('kwargs', ..., ..., 'var_keyword')), + ..., + ) + + self.assertEqual(self.signature(Bar), ((), ...)) + self.assertEqual(self.signature(Bar.__new__), varargs_signature) + self.assertEqual(self.signature(Bar, follow_wrapped=False), + varargs_signature) + self.assertEqual(self.signature(Bar.__new__, follow_wrapped=False), + varargs_signature) + def test_signature_on_class_with_init(self): class C: def __init__(self, b): diff --git a/Lib/test/test_warnings/__init__.py b/Lib/test/test_warnings/__init__.py index 1716acb46b93b0..66853f30d47900 100644 --- a/Lib/test/test_warnings/__init__.py +++ b/Lib/test/test_warnings/__init__.py @@ -2010,10 +2010,70 @@ async def coro(self): self.assertFalse(inspect.iscoroutinefunction(Cls.sync)) self.assertTrue(inspect.iscoroutinefunction(Cls.coro)) + def test_inspect_class_signature(self): + class Cls1: # no __init__ or __new__ + pass + + class Cls2: # __new__ only + def __new__(cls, x, y): + return super().__new__(cls) + + class Cls3: # __init__ only + def __init__(self, x, y): + pass + + class Cls4: # __new__ and __init__ + def __new__(cls, x, y): + return super().__new__(cls) + + def __init__(self, x, y): + pass + + class Cls5(Cls1): # inherits no __init__ or __new__ + pass + + class Cls6(Cls2): # inherits __new__ only + pass + + class Cls7(Cls3): # inherits __init__ only + pass + + class Cls8(Cls4): # inherits __new__ and __init__ + pass + + # The `@deprecated` decorator will update the class in-place. + # Test the child classes first. + for cls in reversed((Cls1, Cls2, Cls3, Cls4, Cls5, Cls6, Cls7, Cls8)): + with self.subTest(f'class {cls.__name__} signature'): + try: + original_signature = inspect.signature(cls) + except ValueError: + original_signature = None + try: + original_new_signature = inspect.signature(cls.__new__) + except ValueError: + original_new_signature = None + + deprecated_cls = deprecated("depr")(cls) + + try: + deprecated_signature = inspect.signature(deprecated_cls) + except ValueError: + deprecated_signature = None + self.assertEqual(original_signature, deprecated_signature) + + try: + deprecated_new_signature = inspect.signature(deprecated_cls.__new__) + except ValueError: + deprecated_new_signature = None + self.assertEqual(original_new_signature, deprecated_new_signature) + + def setUpModule(): py_warnings.onceregistry.clear() c_warnings.onceregistry.clear() + tearDownModule = setUpModule if __name__ == "__main__": diff --git a/Misc/NEWS.d/next/Library/2025-04-03-17-19-42.gh-issue-119605.c7QXAA.rst b/Misc/NEWS.d/next/Library/2025-04-03-17-19-42.gh-issue-119605.c7QXAA.rst new file mode 100644 index 00000000000000..cf8065afc2e5b7 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-04-03-17-19-42.gh-issue-119605.c7QXAA.rst @@ -0,0 +1,4 @@ +Respect ``follow_wrapped`` for :meth:`!__init__` and :meth:`!__new__` methods +when getting the class signature for a class with :func:`inspect.signature`. +Preserve class signature after wrapping with :func:`warnings.deprecated`. +Patch by Xuehai Pan.