]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-112139: Add `inspect.Signature.format` and use it in `pydoc` (#112143)
authorNikita Sobolev <mail@sobolevn.me>
Sat, 2 Dec 2023 23:39:43 +0000 (02:39 +0300)
committerGitHub <noreply@github.com>
Sat, 2 Dec 2023 23:39:43 +0000 (15:39 -0800)
Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
Doc/library/inspect.rst
Lib/inspect.py
Lib/pydoc.py
Lib/test/test_inspect/test_inspect.py
Lib/test/test_pydoc.py
Misc/NEWS.d/next/Library/2023-11-16-10-42-15.gh-issue-112139.WpHosf.rst [new file with mode: 0644]

index 94c5d1c8979afd7484be89e3e1e4b32dd97aae0e..08522510f9ab4446f463d0bd98bf41370aadc563 100644 (file)
@@ -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
index aaa22bef8966028fb8d01bf1c576eec188fa6a58..079385abbc7bb23c5fbc5c14b4eb12aa24d15f9f 100644 (file)
@@ -3316,6 +3316,16 @@ class Signature:
         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
@@ -3353,6 +3363,8 @@ class Signature:
             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)
index be41592cc64bad5bca3f60aa5266f791562df7d7..83c74a75cd1c004067d21a7cd5b070923ededd80 100755 (executable)
@@ -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:
index e75682f881ab346842743ef5be550934403856ae..09d50859970c99d3fd0129cd053501dca7e1b84b 100644 (file)
@@ -3796,26 +3796,36 @@ class TestSignatureObject(unittest.TestCase):
             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
@@ -3826,19 +3836,85 @@ class TestSignatureObject(unittest.TestCase):
 
         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:
index 745717f492e07a7c5e5e48ada946d12156daba9b..eb50510e12b7b64e2565ae4296e95a6b5ed1f806 100644 (file)
@@ -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
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 (file)
index 0000000..090dc88
--- /dev/null
@@ -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.