Skip to content

Commit

Permalink
pythongh-112139: Add inspect.Signature.format and use it in pydoc (
Browse files Browse the repository at this point in the history
…python#112143)

Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
  • Loading branch information
sobolevn and JelleZijlstra authored Dec 2, 2023
1 parent 0229d2a commit a9574c6
Show file tree
Hide file tree
Showing 6 changed files with 205 additions and 11 deletions.
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.

0 comments on commit a9574c6

Please sign in to comment.