Skip to content

[3.13] gh-119605: Respect follow_wrapped for __init__ and __new__ when getting class signature with inspect.signature (GH-132055) #133277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -2000,7 +2000,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.
Expand All @@ -2009,12 +2009,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


Expand Down Expand Up @@ -2589,12 +2597,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
Expand Down Expand Up @@ -2634,10 +2656,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:
Expand Down
40 changes: 39 additions & 1 deletion Lib/test/test_inspect/test_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3967,7 +3967,6 @@ def wrapped_foo_call():
('b', ..., ..., "positional_or_keyword")),
...))


def test_signature_on_class(self):
class C:
def __init__(self, a):
Expand Down Expand Up @@ -4144,6 +4143,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):
Expand Down
60 changes: 60 additions & 0 deletions Lib/test/test_warnings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1826,10 +1826,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__":
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading