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-101293: Fix support of custom callables and types in inspect.Signature.from_callable() #115530

Conversation

serhiy-storchaka
Copy link
Member

@serhiy-storchaka serhiy-storchaka commented Feb 15, 2024

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.

….Signature.from_callable()

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 Outdated
@@ -2533,7 +2542,8 @@ def _signature_from_callable(obj, *,
# Unwrap until we find an explicit signature or a MethodType (which will be
# handled explicitly below).
obj = unwrap(obj, stop=(lambda f: hasattr(f, "__signature__")
or isinstance(f, types.MethodType)))
or isinstance(f, types.MethodType)
or not callable(f.__wrapped__)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what scenario, is f.__wrapped__ not callable? It might worth some comments as it's not easy to follow.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When it is a data descriptor. For example staticmethod.__wrapped__.

It is actually a workaround of issue #112006. We need obj to be a callable, so we can use this workaround here. But more general solution can replace it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay sure that makes sense. I still believe that worth some comments about it (the comments above covered the other cases listed and they are much easier to follow than f.__wrapped__)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a comment.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workaround no longer needed.

Lib/inspect.py Outdated
else:
factory_method = None
new = _signature_get_user_defined_method(obj, '__new__')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like __new__ is the only exemption that continues to use this original method. All the others are replaced with the new logic which uses getattr_static to avoid descriptor resolving. Is there a reason for that? Can we simply change _signature_get_user_defined_method?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It reproduces the logic in the C code (see slot_tp_call, slot_tp_new and slot_tp_init). getattr_static() should be used for __call__ and __init__, and getattr() for __new__.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think _signature_get_user_defined_method is still a pretty self-explanatory name for the method. The only usage for this private method is to get user defined __new__, __init__ and __call__ methods. Can we do the check logic inside the method, asserting the input method to check, and use getattr() / getattr_static() accordingly? The only difference between __new__ and the other two is which function to use right? We don't even need the _descriptor_get function, and we don't need to change the logic in this function.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do this, but it will split the logic on two parts, 600 lines apart one from other. There are other differences:

  • Note that the code for __new__ is followed by break (to call _signature_bound_method() if skip_bound_arg is true), while the code for other methods immediately returns. So some logic will be here, and some in _signature_get_user_defined_method().

  • The code for non-type's __call__ does not have restrictions for builtin types. In future, I want to loosen restrictions for other methods too, so isinstance(meth, _NonUserDefinedCallables) will be different for every method, and we may need to check the type of the descriptor before resolving it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I still think the logic is hard to follow. It took me a while to figure out that the key thing changed is that init and call are currently bound methods, so they do not need the strip of the first parameter - this logic for new however, is far down.

I still believe that we should put the new descriptor/getattr_static logic into _signature_bound_method() because that's exactly what they do. You still have the old function and that's having two super similar routine far away from each other.

I think the logic in _signature_from_callable should be as simple as

call = _signature_get_user_defined_method(type(obj), '__call__')
new = _signature_get_user_defined_method(obj, '__new__')
init = _signature_get_user_defined_method(obj, '__init__')

However, we need to deal with the bound-issue, which is what slot_tp_call does - we can just return another indicator:

call, unbound = _signature_get_user_defined_method(type(obj), '__call__')

We would significantly simplify the logic in _signature_from_callable and get what we need (function and whether it's bounded) with a single function call.

Then, inside isinstance(obj, type) check, before if sig is None:, we can simply do

if sig is not None:
    if unbound and skip_bound_arg:
        return _signature_bound_method(sig)
    return sig

Then of course the following code does not need the if check protection as we ensured sig is not None at that point.

This way, we have all the related logic in two places:

  1. _signature_get_user_defined_method that "magically" does the check and resolves the descriptor.
  2. A continuous block in if isinstance(obj, type): that solves everything.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My goal was to minimize the diffs for easier review and do refactoring in the following PR. But if you prefer, I do some refactoring in this PR (complete refactoring will be possible after making some other behavior changes).

I tried to implement your suggestions, except returning unbound, because I do not see how it can help.

Lib/inspect.py Show resolved Hide resolved
pass

self.assertEqual(C(['a', 'bc']), 'a:bc')
# BUG: Returns '<Signature (b)>'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some doubts listing the bug as a test case. If we know it's a wrong answer, we should not test that the answer is accurately generated. It's possible in the future something changed and this wrong result is corrected, or converted to another wrong one, and the test would fail - and people will be confused unless they look at the source test code and realize it was labeled "BUG". It may help for the "backward compatibility" if we consider the bug as a compatibility we need to keep track of, but I still have concerns for this. If we do want to keep track of it, I think a #TODO would be much better.

Copy link
Member Author

@serhiy-storchaka serhiy-storchaka Feb 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely a bug. The current code ignores __call__ signature and gets the signature from __init__, which is not even called. I used label TODO if signature() currently raises ValueError, but can get a right result, and label BUG if it successfully returns wrong result. The test is added to show what the right answer is. If in the future something changed and this wrong result is corrected, it will be good, we will remove assertRaises() and the comment.

We can also split these tests on small methods and use the @expectedFailure decorator, if it matters.

BTW, I have a patch that fixes these TODOs and BUGs, but I am not certain about it, so I kept only safer changes here.

def __init__(self, b):
pass

C(1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that this is to test whether the method works without an exception, but this statement is a bit weird by itself. What are the possible use cases for a @staticmethod __init__? To be honest all the decorators on __init__ and __new__ are so weird that I doubt if there's anyone actually using them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are weird because they are the only examples that can be created using existing builtin classes. But in future we could implement __init__, __new__ and __call__ in C for some classes. By testing with different types of callables and descriptors we increase confidence in working with arbitrary appropriate types.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the first sentence I actually meant the C(1) statement - it does not explicitly check against anything. I understand that it tests whether an exception will be raised when instantiating the object, but it still looks a bit weird.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add comments.

@serhiy-storchaka
Copy link
Member Author

@gaogaotiantian, which of my comments answered your concerns and which ones were unconvincing?

@gaogaotiantian
Copy link
Member

Thank you for your patience. I approved the change for now because obviously you have future plans with the code and some of them will be refactored soon, so it's not worth it to debate on the best shape of the current situation. It's completely acceptable now.

However, as I'm not the core-dev so you probably need another person to take a look at the code?

@serhiy-storchaka serhiy-storchaka merged commit 59167c9 into python:main Mar 1, 2024
31 checks passed
@serhiy-storchaka serhiy-storchaka deleted the inspect-signature-from_callable-2 branch March 1, 2024 11:32
@serhiy-storchaka
Copy link
Member Author

Thank you for your review @gaogaotiantian.

@serhiy-storchaka serhiy-storchaka added needs backport to 3.11 only security fixes needs backport to 3.12 bug and security fixes labels Mar 1, 2024
@miss-islington-app
Copy link

Thanks @serhiy-storchaka for the PR 🌮🎉.. I'm working now to backport this PR to: 3.11.
🐍🍒⛏🤖

@miss-islington-app
Copy link

Thanks @serhiy-storchaka for the PR 🌮🎉.. I'm working now to backport this PR to: 3.12.
🐍🍒⛏🤖

miss-islington pushed a commit to miss-islington/cpython that referenced this pull request Mar 1, 2024
….Signature.from_callable() (pythonGH-115530)

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.
(cherry picked from commit 59167c9)

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
@bedevere-app
Copy link

bedevere-app bot commented Mar 1, 2024

GH-116197 is a backport of this pull request to the 3.11 branch.

@bedevere-app bedevere-app bot removed the needs backport to 3.11 only security fixes label Mar 1, 2024
miss-islington pushed a commit to miss-islington/cpython that referenced this pull request Mar 1, 2024
….Signature.from_callable() (pythonGH-115530)

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.
(cherry picked from commit 59167c9)

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
@bedevere-app
Copy link

bedevere-app bot commented Mar 1, 2024

GH-116198 is a backport of this pull request to the 3.12 branch.

@bedevere-app bedevere-app bot removed the needs backport to 3.12 bug and security fixes label Mar 1, 2024
serhiy-storchaka added a commit that referenced this pull request Mar 1, 2024
…t.Signature.from_callable() (GH-115530) (GH-116197)

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.
(cherry picked from commit 59167c9)

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
serhiy-storchaka added a commit that referenced this pull request Mar 1, 2024
…t.Signature.from_callable() (GH-115530) (GH-116198)

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.
(cherry picked from commit 59167c9)

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
woodruffw pushed a commit to woodruffw-forks/cpython that referenced this pull request Mar 4, 2024
….Signature.from_callable() (pythonGH-115530)

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.
adorilson pushed a commit to adorilson/cpython that referenced this pull request Mar 25, 2024
….Signature.from_callable() (pythonGH-115530)

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.
diegorusso pushed a commit to diegorusso/cpython that referenced this pull request Apr 17, 2024
….Signature.from_callable() (pythonGH-115530)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants