From 4a2e034e5f36b7e37a73c7ffce4fa80af8bab877 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 16 Nov 2023 10:42:41 +0300 Subject: [PATCH 1/5] gh-112139: Add `pretty` to `inspect.Signature` and use it in `pydoc` --- Lib/inspect.py | 8 ++- Lib/pydoc.py | 2 +- Lib/test/test_pydoc.py | 59 +++++++++++++++++++ ...-11-16-10-42-15.gh-issue-112139.WpHosf.rst | 3 + 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst diff --git a/Lib/inspect.py b/Lib/inspect.py index aaa22bef896602..8878b78c46d761 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -3315,7 +3315,7 @@ def __setstate__(self, state): def __repr__(self): return '<{} {}>'.format(self.__class__.__name__, self) - def __str__(self): + def __str__(self, *, pretty=False): result = [] render_pos_only_separator = False render_kw_only_separator = True @@ -3352,7 +3352,11 @@ def __str__(self): # flag was not reset to 'False' result.append('/') - rendered = '({})'.format(', '.join(result)) + params = ', '.join(result) + if pretty and len(params) > 78: # 80 - '(' - ')' + rendered = '(\n {}\n)'.format(',\n '.join(result)) + else: + rendered = '({})'.format(params) if self.return_annotation is not _empty: anno = formatannotation(self.return_annotation) diff --git a/Lib/pydoc.py b/Lib/pydoc.py index be41592cc64bad..e23d381caa30f7 100755 --- a/Lib/pydoc.py +++ b/Lib/pydoc.py @@ -201,7 +201,7 @@ def _getargspec(object): try: signature = inspect.signature(object) if signature: - return str(signature) + return signature.__str__(pretty=True) except (ValueError, TypeError): argspec = getattr(object, '__text_signature__', None) if argspec: diff --git a/Lib/test/test_pydoc.py b/Lib/test/test_pydoc.py index 70c5ebd694ca88..ba9c388a9c5025 100644 --- a/Lib/test/test_pydoc.py +++ b/Lib/test/test_pydoc.py @@ -870,6 +870,65 @@ class B(A) for expected_line in expected_lines: self.assertIn(expected_line, as_text) + def test_long_signatures(self): + from typing import Callable, 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: 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: 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 (if defined) + | + | __weakref__ + | list of weak references to the object (if defined) +''' % __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: Callable[[Annotated[int, 'Some doc']], str], + arg2: Literal[1, 2, 3, 4, 5, 6, 7, 8] +) -> Annotated[int, 'Some other'] +''' % __name__) + def test__future__imports(self): # __future__ features are excluded from module help, # except when it's the __future__ module itself diff --git a/Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst b/Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst new file mode 100644 index 00000000000000..c47d495c0592b9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst @@ -0,0 +1,3 @@ +Add ``pretty`` param to ``__str__`` method of :class:`inspect.Signature` and +use it in :mod:`pydoc` to render more readable signatures that have new +lines between parameters. From e41a55811e7c54aa789b30fc969c8c3c3cfb7030 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sat, 18 Nov 2023 11:35:16 +0300 Subject: [PATCH 2/5] Add `inspect.Signature.format` method --- Doc/library/inspect.rst | 11 +++ Lib/inspect.py | 18 +++-- Lib/pydoc.py | 5 +- Lib/test/test_inspect/test_inspect.py | 70 ++++++++++++++++--- Lib/test/test_pydoc.py | 38 ++++++++-- ...-11-16-10-42-15.gh-issue-112139.WpHosf.rst | 4 +- 6 files changed, 124 insertions(+), 22 deletions(-) diff --git a/Doc/library/inspect.rst b/Doc/library/inspect.rst index b463c0b6d0e402..1dacd286bf4ce4 100644 --- a/Doc/library/inspect.rst +++ b/Doc/library/inspect.rst @@ -752,6 +752,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* 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. + + .. versionadded:: 3.13 + .. classmethod:: Signature.from_callable(obj, *, follow_wrapped=True, globalns=None, localns=None) Return a :class:`Signature` (or its subclass) object for a given callable diff --git a/Lib/inspect.py b/Lib/inspect.py index 8878b78c46d761..079385abbc7bb2 100644 --- a/Lib/inspect.py +++ b/Lib/inspect.py @@ -3315,7 +3315,17 @@ def __setstate__(self, state): def __repr__(self): return '<{} {}>'.format(self.__class__.__name__, self) - def __str__(self, *, pretty=False): + 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 @@ -3352,11 +3362,9 @@ def __str__(self, *, pretty=False): # flag was not reset to 'False' result.append('/') - params = ', '.join(result) - if pretty and len(params) > 78: # 80 - '(' - ')' + rendered = '({})'.format(', '.join(result)) + if max_width is not None and len(rendered) > max_width: rendered = '(\n {}\n)'.format(',\n '.join(result)) - else: - rendered = '({})'.format(params) if self.return_annotation is not _empty: anno = formatannotation(self.return_annotation) diff --git a/Lib/pydoc.py b/Lib/pydoc.py index e23d381caa30f7..83c74a75cd1c00 100755 --- a/Lib/pydoc.py +++ b/Lib/pydoc.py @@ -201,7 +201,10 @@ def _getargspec(object): try: signature = inspect.signature(object) if signature: - return signature.__str__(pretty=True) + name = getattr(object, '__name__', '') + # function are always single-line and should not be formatted + max_width = (80 - len(name)) if name != '' else None + return signature.format(max_width=max_width) except (ValueError, TypeError): argspec = getattr(object, '__text_signature__', None) if argspec: diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index becbb0498bbb3f..6d4036a2c5cf82 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -3788,26 +3788,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 @@ -3818,19 +3828,59 @@ 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()) - self.assertEqual(str(S(parameters=[P('foo', P.POSITIONAL_ONLY)])), - '(foo, /)') + 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), - P('bar', P.VAR_KEYWORD)])), - '(foo, /, **bar)') + 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_POSITIONAL)])), - '(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()) + + 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_replace_parameters(self): def test(a, b) -> 42: diff --git a/Lib/test/test_pydoc.py b/Lib/test/test_pydoc.py index ba9c388a9c5025..e7a9e14426d7f7 100644 --- a/Lib/test/test_pydoc.py +++ b/Lib/test/test_pydoc.py @@ -871,7 +871,8 @@ class B(A) self.assertIn(expected_line, as_text) def test_long_signatures(self): - from typing import Callable, Literal, Annotated + from collections.abc import Callable + from typing import Literal, Annotated class A: def __init__(self, @@ -888,7 +889,7 @@ def __init__(self, class A(builtins.object) | A( - | arg1: Callable[[int, int, int], str], + | arg1: collections.abc.Callable[[int, int, int], str], | arg2: Literal['some value', 'other value'], | arg3: Annotated[int, 'some docs about this type'] | ) -> None @@ -897,7 +898,7 @@ class A(builtins.object) | | __init__( | self, - | arg1: Callable[[int, int, int], str], + | arg1: collections.abc.Callable[[int, int, int], str], | arg2: Literal['some value', 'other value'], | arg3: Annotated[int, 'some docs about this type'] | ) -> None @@ -924,9 +925,38 @@ def func( self.assertEqual(doc, '''Python Library Documentation: function func in module %s func( - arg1: Callable[[Annotated[int, 'Some doc']], str], + 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 in module %s + + lambda very_long_parameter_name_that_should_not_fit_into_a_single_line, second_very_long_parameter_name ''' % __name__) def test__future__imports(self): diff --git a/Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst b/Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst index c47d495c0592b9..090dc8847d9556 100644 --- a/Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst +++ b/Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst @@ -1,3 +1,3 @@ -Add ``pretty`` param to ``__str__`` method of :class:`inspect.Signature` and -use it in :mod:`pydoc` to render more readable signatures that have new +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. From effb172023635b8d01f74ab6504e3be281343d99 Mon Sep 17 00:00:00 2001 From: Nikita Sobolev Date: Sat, 2 Dec 2023 13:02:00 +0300 Subject: [PATCH 3/5] Update Doc/library/inspect.rst Co-authored-by: Jelle Zijlstra --- Doc/library/inspect.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Doc/library/inspect.rst b/Doc/library/inspect.rst index 1dacd286bf4ce4..28a571fcace9d2 100644 --- a/Doc/library/inspect.rst +++ b/Doc/library/inspect.rst @@ -756,9 +756,9 @@ function. 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*, + 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 From 6c604a59fbe20e8e5949d9e513b2fbfb3cc3c25e Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sat, 2 Dec 2023 13:05:56 +0300 Subject: [PATCH 4/5] Add more tests --- Lib/test/test_inspect/test_inspect.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index 6d4036a2c5cf82..915e06436d0d2b 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -3882,6 +3882,32 @@ def func(x: Annotated[int, 'meta'], y: Literal['a', 'b'], z: 'LiteralString'): 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: pass From b1f632941c7d665c32e9b92d54938a59f866cb4f Mon Sep 17 00:00:00 2001 From: sobolevn Date: Sat, 2 Dec 2023 13:40:12 +0300 Subject: [PATCH 5/5] Merge main --- Lib/test/test_pydoc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pydoc.py b/Lib/test/test_pydoc.py index a616432dba28ec..eb50510e12b7b6 100644 --- a/Lib/test/test_pydoc.py +++ b/Lib/test/test_pydoc.py @@ -907,10 +907,10 @@ class A(builtins.object) | Data descriptors defined here: | | __dict__ - | dictionary for instance variables (if defined) + | dictionary for instance variables | | __weakref__ - | list of weak references to the object (if defined) + | list of weak references to the object ''' % __name__) def func(