Skip to content
Closed
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
16 changes: 14 additions & 2 deletions Doc/library/inspect.rst
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ The Signature object represents the call signature of a callable object and its
return annotation. To retrieve a Signature object, use the :func:`signature`
function.

.. function:: signature(callable, \*, follow_wrapped=True)
.. function:: signature(callable_or_text, \*, follow_wrapped=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry, I don't like the signature() function to accept both callables and text. This introduces extra complexity to the API for a very narrow use case.

It also makes the function too dynamic: currently, if a text value is passed to signature() by mistake the user would get an exception saying that a callable was expected. With this patch they'll likely get a ParseError.

IMO a public documented Signature.from_text() API is enough.


Return a :class:`Signature` object for the given ``callable``::

Expand All @@ -570,7 +570,8 @@ function.
<class 'int'>

Accepts a wide range of Python callables, from plain functions and classes to
:func:`functools.partial` objects.
:func:`functools.partial` objects. Text (string) signature are also
supported. See :func:`Signature.from_text`.

Raises :exc:`ValueError` if no signature can be provided, and
:exc:`TypeError` if that type of object is not supported.
Expand All @@ -584,6 +585,9 @@ function.
``callable`` specifically (``callable.__wrapped__`` will not be used to
unwrap decorated callables.)

.. versionchanged:: 3.9
Supports converting signature strings into :class:`Signature` objects.

.. note::

Some callables may not be introspectable in certain implementations of
Expand Down Expand Up @@ -677,6 +681,14 @@ function.

.. versionadded:: 3.5

.. classmethod:: Signature.from_text(text)

Return a :class:`Signature` (or its subclass) object for a given string.

For any signature object, ``str(sig)`` will round-trip through
``from_text()``.

.. versionadded:: 3.9

.. class:: Parameter(name, kind, \*, default=Parameter.empty, annotation=Parameter.empty)

Expand Down
49 changes: 42 additions & 7 deletions Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -1955,18 +1955,32 @@ def _signature_strip_non_python_syntax(signature):
return clean_signature, self_parameter, last_positional_only


def _signature_fromstr(cls, obj, s, skip_bound_arg=True):
def _signature_fromstr(cls, obj, text, skip_bound_arg=True):
"""Private helper to parse content of '__text_signature__'
and return a Signature based on it.
"""
modname = getattr(obj, '__module__', None)
_self = getattr(obj, '__self__', None)
try:
return _signature_from_text(cls, text, modname, _self, skip_bound_arg)
except ValueError as exc:
if getattr(exc, '_syntax', False):
raise ValueError("{!r} builtin has invalid signature".format(obj))
raise


def _signature_from_text(cls, text, module_name=None, _self=None, skip_bound_arg=True):
"""Private helper to parse a signature string in Argument Clinic's
extended signature format.
"""
# Lazy import ast because it's relatively heavy and
# it's not used for other than this function.
import ast

Parameter = cls._parameter_cls

clean_signature, self_parameter, last_positional_only = \
_signature_strip_non_python_syntax(s)
(clean_signature, self_parameter, last_positional_only
) = _signature_strip_non_python_syntax(text)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: I'd keep the \ :)


program = "def foo" + clean_signature + ": pass"

Expand All @@ -1976,7 +1990,9 @@ def _signature_fromstr(cls, obj, s, skip_bound_arg=True):
module = None

if not isinstance(module, ast.Module):
raise ValueError("{!r} builtin has invalid signature".format(obj))
exc = ValueError(f"invalid signature {text!r}")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Commenting the if block on master and in this PR has no test failure so I guess this code was not tested. Perhaps a test could be added where __text_signature__ has a invalid syntax as signature to see if ValueError is raised like below :

def test_python_function_invalid_text_signature(self):
    def func(*args, **kwargs):
        pass
    func.__text_signature__ = '($self, **kwargs, a)'
    with self.assertRaises(ValueError) as e:
        sig = inspect.signature(func)

exc._syntax = True
raise exc

f = module.body[0]

Expand All @@ -1986,7 +2002,6 @@ def _signature_fromstr(cls, obj, s, skip_bound_arg=True):

module = None
module_dict = {}
module_name = getattr(obj, '__module__', None)
if module_name:
module = sys.modules.get(module_name, None)
if module:
Expand Down Expand Up @@ -2080,7 +2095,6 @@ def p(name_node, default_node, default=empty):
# - We don't strip first bound argument if
# skip_bound_arg is False.
assert parameters
_self = getattr(obj, '__self__', None)
self_isbound = _self is not None
self_ismodule = ismodule(_self)
if self_isbound and (self_ismodule or skip_bound_arg):
Expand Down Expand Up @@ -2812,6 +2826,24 @@ def __init__(self, parameters=None, *, return_annotation=_empty,
self._parameters = types.MappingProxyType(params)
self._return_annotation = return_annotation

@classmethod
def from_text(cls, text, *, skip_bound_arg=True):
"""Constructs Signature for the given string.

The expected format matches what Signature.__str__() returns.
For example:

func()
func(a, b)
func(a: int, b: str)
func(a, b=None, /, c, d=None, *args, e, f=None, **kwargs)

For the most part this will round-trip with Signature.__str__().
"""
# See Argument Clinic's extended signature format (PEP 436).
return _signature_from_text(cls, text,
skip_bound_arg=skip_bound_arg)

@classmethod
def from_function(cls, func):
"""Constructs Signature for the given python function.
Expand Down Expand Up @@ -3090,7 +3122,10 @@ def __str__(self):

def signature(obj, *, follow_wrapped=True):
"""Get a signature object for the passed callable."""
return Signature.from_callable(obj, follow_wrapped=follow_wrapped)
if isinstance(obj, str):
return Signature.from_text(obj)
else:
return Signature.from_callable(obj, follow_wrapped=follow_wrapped)


def _main():
Expand Down
106 changes: 106 additions & 0 deletions Lib/test/test_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3166,6 +3166,112 @@ class MySignature(inspect.Signature): pass
sig = MySignature.from_callable(_pickle.Pickler)
self.assertTrue(isinstance(sig, MySignature))

def test_signature_from_text(self):
def expect(params, returns=None):
for i, param in enumerate(params):
kind = None
kwargs = None
if isinstance(param, str):
name = param
elif len(param) == 1:
name, = param
elif len(param) == 2:
name, kind = param
else:
name, kind, kwargs = param
if kind is None:
kind = inspect.Parameter.POSITIONAL_OR_KEYWORD
params[i] = inspect.Parameter(name, kind, **kwargs or {})
if returns:
return inspect.Signature(params, return_annotation=returns)
else:
return inspect.Signature(params)
tests = [
# (text, expected, expected-no-skip)
('()',
expect([]),
None),
('(a, b)',
expect(['a', 'b']),
None),
('(a, b=2, /, c=3, *args, d=4, e, **kwargs)',
expect([
('a', inspect.Parameter.POSITIONAL_ONLY),
('b', inspect.Parameter.POSITIONAL_ONLY, {'default': 2}),
('c', None, {'default': 3}),
('args', inspect.Parameter.VAR_POSITIONAL),
('d', inspect.Parameter.KEYWORD_ONLY, {'default': 4}),
('e', inspect.Parameter.KEYWORD_ONLY, ),
('kwargs', inspect.Parameter.VAR_KEYWORD),
]),
None),
('(a, b: int, c=3, *, d: Int = 4)',
ValueError('Annotations are not currently supported'),
# XXX Uncomment once annotations *are* supported.
#expect([
# ('a',),
# ('b', None, {'annotation': int}),
# ('c', None, {'default': 3}),
# ('d', inspect.Parameter.KEYWORD_ONLY, {'default': 4, 'annotation': 'Int'}),
# ]),
None),
('(*args, **kwargs)',
expect([
('args', inspect.Parameter.VAR_POSITIONAL),
('kwargs', inspect.Parameter.VAR_KEYWORD),
]),
None),
('func()',
expect([]),
None),

# builtins
(pow, None, None),
(range, None, None),
(__import__, None, None),
]
for text, expected, expected_no_skip in tests:
if isinstance(text, str):
obj = None
else:
obj = text
text = getattr(obj, '__text_signature__', None)
if not text:
continue
if text.startswith('($module, '):
text = '(' + text[9:]
if expected is None:
expected = inspect.signature(obj)
if expected_no_skip is None:
expected_no_skip = expected
with self.subTest(text if obj is None else f'<{obj.__name__}> {text}'):
if isinstance(expected, Exception):
with self.assertRaises(type(expected)) as cm:
inspect.Signature.from_text(text, skip_bound_arg=True)
self.assertEqual(str(cm.exception), str(expected))
else:
sig = inspect.signature(text)
sig_default = inspect.Signature.from_text(text)
sig_skip = inspect.Signature.from_text(text, skip_bound_arg=True)

self.assertEqual(sig, expected)
self.assertEqual(sig_default, expected)
self.assertEqual(sig_skip, expected)

if isinstance(expected_no_skip, Exception):
with self.assertRaises(type(expected_no_skip)) as cm:
inspect.Signature.from_text(text, skip_bound_arg=False)
self.assertEqual(str(cm.exception), str(expected_no_skip))
else:
sig_no_skip = inspect.Signature.from_text(text, skip_bound_arg=False)
# round trip
sig_roundtrip = inspect.Signature.from_text(
str(sig_no_skip),
skip_bound_arg=False)

self.assertEqual(sig_no_skip, expected)
self.assertEqual(sig_roundtrip, sig_no_skip)

def test_signature_definition_order_preserved_on_kwonly(self):
for fn in signatures_with_lexicographic_keyword_only_parameters():
signature = inspect.signature(fn)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add inspect.Signature.from_text().