Skip to content
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

gh-119605: Respect follow_wrapped for __init__ and __new__ when getting class signature with inspect.signature #132055

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
aa8865e
Preserve class signature after wrapping with `@warnings.deprecated`
XuehaiPan Jun 3, 2024
d093b57
📜🤖 Added by blurb_it.
blurb-it[bot] Apr 3, 2025
9664211
Add test to class signature
XuehaiPan Apr 3, 2025
a1dd52e
Fix tests
XuehaiPan Apr 3, 2025
668adb3
Merge branch 'main' into deprecated-signature
XuehaiPan Apr 3, 2025
68eebad
Handle potential assignment failure
XuehaiPan Apr 3, 2025
57c61c3
Update tests
XuehaiPan Apr 4, 2025
6e28f0b
Use `__signature__`
XuehaiPan Apr 4, 2025
88666b0
Merge branch 'main' into deprecated-signature
XuehaiPan Apr 4, 2025
43b0977
Remove duplicate in tests
XuehaiPan Apr 4, 2025
4dd8b70
Unwrap decorated __new__ and __init__ in inspect
XuehaiPan Apr 4, 2025
6b7bd71
Unwrap decorated __new__ and __init__ in inspect
XuehaiPan Apr 4, 2025
bb2c39b
Merge branch 'main' into deprecated-signature
XuehaiPan Apr 4, 2025
ca8aa96
Fix tests
XuehaiPan Apr 4, 2025
636c687
Fix tests
XuehaiPan Apr 4, 2025
97a17b2
Merge branch 'main' into deprecated-signature
XuehaiPan Apr 4, 2025
930881f
Merge branch 'main' into deprecated-signature
XuehaiPan Apr 5, 2025
11f8218
Merge branch 'main' into deprecated-signature
XuehaiPan Apr 6, 2025
198f8ee
Revert unneeded changes
XuehaiPan Apr 6, 2025
2c50424
Respect `follow_wrapper_chains`
XuehaiPan Apr 6, 2025
c62184a
Update news entry
XuehaiPan Apr 6, 2025
759d403
Add test for inspect
XuehaiPan Apr 6, 2025
c27ee65
Update news entry
XuehaiPan Apr 6, 2025
c148a7d
Update news entry
XuehaiPan Apr 6, 2025
72c1d5a
Update news entry
XuehaiPan Apr 6, 2025
2b7445b
Update news entry
XuehaiPan Apr 6, 2025
e8a3a1e
Merge branch 'main' into deprecated-signature
XuehaiPan Apr 6, 2025
2517a21
Fix tests
XuehaiPan Apr 6, 2025
44f103f
Simplify branches
XuehaiPan Apr 6, 2025
6693ead
Handle all method descriptor types
XuehaiPan Apr 6, 2025
82ab31e
Merge branch 'main' into deprecated-signature
XuehaiPan Apr 7, 2025
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 @@ -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.
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
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 @@ -3845,7 +3845,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 @@ -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 wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper

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__)

object_new_signature = (
(('args', ..., ..., 'var_positional'),
('kwargs', ..., ..., 'var_keyword')),
...,
)

self.assertEqual(self.signature(Bar), ((), ...))
self.assertEqual(self.signature(Bar.__new__), object_new_signature)
self.assertEqual(self.signature(Bar, follow_wrapped=False),
object_new_signature)
self.assertEqual(self.signature(Bar.__new__, follow_wrapped=False),
object_new_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 @@ -1851,10 +1851,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