Skip to content

gh-112139: Add inspect.Signature.format and use it in pydoc #112143

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 7 commits into from
Dec 2, 2023
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
11 changes: 11 additions & 0 deletions Doc/library/inspect.rst
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,17 @@ function.
Signature objects are also supported by generic function
:func:`copy.replace`.

.. method:: format(*, max_width=None)

Convert signature object to string.

If *max_width* is passed, the method will attempt to fit
the signature into lines of at most *max_width* characters.
If the signature is longer than *max_width*,
all parameters will be on separate lines.

.. versionadded:: 3.13

.. classmethod:: Signature.from_callable(obj, *, follow_wrapped=True, globals=None, locals=None, eval_str=False)

Return a :class:`Signature` (or its subclass) object for a given callable
Expand Down
12 changes: 12 additions & 0 deletions Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3316,6 +3316,16 @@ def __repr__(self):
return '<{} {}>'.format(self.__class__.__name__, self)

def __str__(self):
return self.format()

def format(self, *, max_width=None):
"""Convert signature object to string.

If *max_width* integer is passed,
signature will try to fit into the *max_width*.
If signature is longer than *max_width*,
all parameters will be on separate lines.
"""
result = []
render_pos_only_separator = False
render_kw_only_separator = True
Expand Down Expand Up @@ -3353,6 +3363,8 @@ def __str__(self):
result.append('/')

rendered = '({})'.format(', '.join(result))
if max_width is not None and len(rendered) > max_width:
rendered = '(\n {}\n)'.format(',\n '.join(result))

if self.return_annotation is not _empty:
anno = formatannotation(self.return_annotation)
Expand Down
5 changes: 4 additions & 1 deletion Lib/pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ def _getargspec(object):
try:
signature = inspect.signature(object)
if signature:
return str(signature)
name = getattr(object, '__name__', '')
# <lambda> function are always single-line and should not be formatted
max_width = (80 - len(name)) if name != '<lambda>' else None
return signature.format(max_width=max_width)
except (ValueError, TypeError):
argspec = getattr(object, '__text_signature__', None)
if argspec:
Expand Down
96 changes: 86 additions & 10 deletions Lib/test/test_inspect/test_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3796,26 +3796,36 @@ def foo(a:int=1, *, b, c=None, **kwargs) -> 42:
pass
self.assertEqual(str(inspect.signature(foo)),
'(a: int = 1, *, b, c=None, **kwargs) -> 42')
self.assertEqual(str(inspect.signature(foo)),
inspect.signature(foo).format())

def foo(a:int=1, *args, b, c=None, **kwargs) -> 42:
pass
self.assertEqual(str(inspect.signature(foo)),
'(a: int = 1, *args, b, c=None, **kwargs) -> 42')
self.assertEqual(str(inspect.signature(foo)),
inspect.signature(foo).format())

def foo():
pass
self.assertEqual(str(inspect.signature(foo)), '()')
self.assertEqual(str(inspect.signature(foo)),
inspect.signature(foo).format())

def foo(a: list[str]) -> tuple[str, float]:
pass
self.assertEqual(str(inspect.signature(foo)),
'(a: list[str]) -> tuple[str, float]')
self.assertEqual(str(inspect.signature(foo)),
inspect.signature(foo).format())

from typing import Tuple
def foo(a: list[str]) -> Tuple[str, float]:
pass
self.assertEqual(str(inspect.signature(foo)),
'(a: list[str]) -> Tuple[str, float]')
self.assertEqual(str(inspect.signature(foo)),
inspect.signature(foo).format())

def test_signature_str_positional_only(self):
P = inspect.Parameter
Expand All @@ -3826,19 +3836,85 @@ def test(a_po, /, *, b, **kwargs):

self.assertEqual(str(inspect.signature(test)),
'(a_po, /, *, b, **kwargs)')
self.assertEqual(str(inspect.signature(test)),
inspect.signature(test).format())

test = S(parameters=[P('foo', P.POSITIONAL_ONLY)])
self.assertEqual(str(test), '(foo, /)')
self.assertEqual(str(test), test.format())

self.assertEqual(str(S(parameters=[P('foo', P.POSITIONAL_ONLY)])),
'(foo, /)')
test = S(parameters=[P('foo', P.POSITIONAL_ONLY),
P('bar', P.VAR_KEYWORD)])
self.assertEqual(str(test), '(foo, /, **bar)')
self.assertEqual(str(test), test.format())

self.assertEqual(str(S(parameters=[
P('foo', P.POSITIONAL_ONLY),
P('bar', P.VAR_KEYWORD)])),
'(foo, /, **bar)')
test = S(parameters=[P('foo', P.POSITIONAL_ONLY),
P('bar', P.VAR_POSITIONAL)])
self.assertEqual(str(test), '(foo, /, *bar)')
self.assertEqual(str(test), test.format())

self.assertEqual(str(S(parameters=[
P('foo', P.POSITIONAL_ONLY),
P('bar', P.VAR_POSITIONAL)])),
'(foo, /, *bar)')
def test_signature_format(self):
from typing import Annotated, Literal

def func(x: Annotated[int, 'meta'], y: Literal['a', 'b'], z: 'LiteralString'):
pass

expected_singleline = "(x: Annotated[int, 'meta'], y: Literal['a', 'b'], z: 'LiteralString')"
expected_multiline = """(
x: Annotated[int, 'meta'],
y: Literal['a', 'b'],
z: 'LiteralString'
)"""
self.assertEqual(
inspect.signature(func).format(),
expected_singleline,
)
self.assertEqual(
inspect.signature(func).format(max_width=None),
expected_singleline,
)
self.assertEqual(
inspect.signature(func).format(max_width=len(expected_singleline)),
expected_singleline,
)
self.assertEqual(
inspect.signature(func).format(max_width=len(expected_singleline) - 1),
expected_multiline,
)
self.assertEqual(
inspect.signature(func).format(max_width=0),
expected_multiline,
)
self.assertEqual(
inspect.signature(func).format(max_width=-1),
expected_multiline,
)

def test_signature_format_all_arg_types(self):
from typing import Annotated, Literal

def func(
x: Annotated[int, 'meta'],
/,
y: Literal['a', 'b'],
*,
z: 'LiteralString',
**kwargs: object,
) -> None:
pass

expected_multiline = """(
x: Annotated[int, 'meta'],
/,
y: Literal['a', 'b'],
*,
z: 'LiteralString',
**kwargs: object
) -> None"""
self.assertEqual(
inspect.signature(func).format(max_width=-1),
expected_multiline,
)

def test_signature_replace_parameters(self):
def test(a, b) -> 42:
Expand Down
89 changes: 89 additions & 0 deletions Lib/test/test_pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,95 @@ class B(A)
for expected_line in expected_lines:
self.assertIn(expected_line, as_text)

def test_long_signatures(self):
from collections.abc import Callable
from typing import Literal, Annotated

class A:
def __init__(self,
arg1: Callable[[int, int, int], str],
arg2: Literal['some value', 'other value'],
arg3: Annotated[int, 'some docs about this type'],
) -> None:
...

doc = pydoc.render_doc(A)
# clean up the extra text formatting that pydoc performs
doc = re.sub('\b.', '', doc)
self.assertEqual(doc, '''Python Library Documentation: class A in module %s

class A(builtins.object)
| A(
| arg1: collections.abc.Callable[[int, int, int], str],
| arg2: Literal['some value', 'other value'],
| arg3: Annotated[int, 'some docs about this type']
| ) -> None
|
| Methods defined here:
|
| __init__(
| self,
| arg1: collections.abc.Callable[[int, int, int], str],
| arg2: Literal['some value', 'other value'],
| arg3: Annotated[int, 'some docs about this type']
| ) -> None
|
| ----------------------------------------------------------------------
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables
|
| __weakref__
| list of weak references to the object
''' % __name__)

def func(
arg1: Callable[[Annotated[int, 'Some doc']], str],
arg2: Literal[1, 2, 3, 4, 5, 6, 7, 8],
) -> Annotated[int, 'Some other']:
...

doc = pydoc.render_doc(func)
# clean up the extra text formatting that pydoc performs
doc = re.sub('\b.', '', doc)
self.assertEqual(doc, '''Python Library Documentation: function func in module %s

func(
arg1: collections.abc.Callable[[typing.Annotated[int, 'Some doc']], str],
arg2: Literal[1, 2, 3, 4, 5, 6, 7, 8]
) -> Annotated[int, 'Some other']
''' % __name__)

def function_with_really_long_name_so_annotations_can_be_rather_small(
arg1: int,
arg2: str,
):
...

doc = pydoc.render_doc(function_with_really_long_name_so_annotations_can_be_rather_small)
# clean up the extra text formatting that pydoc performs
doc = re.sub('\b.', '', doc)
self.assertEqual(doc, '''Python Library Documentation: function function_with_really_long_name_so_annotations_can_be_rather_small in module %s

function_with_really_long_name_so_annotations_can_be_rather_small(
arg1: int,
arg2: str
)
''' % __name__)

does_not_have_name = lambda \
very_long_parameter_name_that_should_not_fit_into_a_single_line, \
second_very_long_parameter_name: ...

doc = pydoc.render_doc(does_not_have_name)
# clean up the extra text formatting that pydoc performs
doc = re.sub('\b.', '', doc)
self.assertEqual(doc, '''Python Library Documentation: function <lambda> in module %s

<lambda> lambda very_long_parameter_name_that_should_not_fit_into_a_single_line, second_very_long_parameter_name
''' % __name__)

def test__future__imports(self):
# __future__ features are excluded from module help,
# except when it's the __future__ module itself
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Add :meth:`Signature.format` to format signatures to string with extra options.
And use it in :mod:`pydoc` to render more readable signatures that have new
lines between parameters.