]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-101552: Allow pydoc to display signatures in source format (#124669)
authorJelle Zijlstra <jelle.zijlstra@gmail.com>
Wed, 9 Oct 2024 05:03:53 +0000 (22:03 -0700)
committerGitHub <noreply@github.com>
Wed, 9 Oct 2024 05:03:53 +0000 (05:03 +0000)
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Doc/library/inspect.rst
Doc/whatsnew/3.14.rst
Lib/inspect.py
Lib/pydoc.py
Lib/test/test_inspect/inspect_deferred_annotations.py [new file with mode: 0644]
Lib/test/test_inspect/test_inspect.py
Lib/test/test_pydoc/test_pydoc.py
Misc/NEWS.d/next/Library/2024-09-27-06-39-32.gh-issue-101552.xYkzag.rst [new file with mode: 0644]

index 853671856b2a144f73bd30a48c597850ecf24616..1eaf1cc5d9a68e86b54888659020b1f10887cfcc 100644 (file)
@@ -694,7 +694,7 @@ and its return annotation. To retrieve a :class:`!Signature` object,
 use the :func:`!signature`
 function.
 
-.. function:: signature(callable, *, follow_wrapped=True, globals=None, locals=None, eval_str=False)
+.. function:: signature(callable, *, follow_wrapped=True, globals=None, locals=None, eval_str=False, annotation_format=Format.VALUE)
 
    Return a :class:`Signature` object for the given *callable*:
 
@@ -725,7 +725,12 @@ function.
    *globals*, *locals*, and *eval_str* parameters are passed
    into :func:`!annotationlib.get_annotations` when resolving the
    annotations; see the documentation for :func:`!annotationlib.get_annotations`
-   for instructions on how to use these parameters.
+   for instructions on how to use these parameters. A member of the
+   :class:`annotationlib.Format` enum can be passed to the
+   *annotation_format* parameter to control the format of the returned
+   annotations. For example, use
+   ``annotation_format=annotationlib.Format.STRING`` to return annotations in string
+   format.
 
    Raises :exc:`ValueError` if no signature can be provided, and
    :exc:`TypeError` if that type of object is not supported.  Also,
@@ -733,7 +738,7 @@ function.
    the ``eval()`` call(s) to un-stringize the annotations in :func:`annotationlib.get_annotations`
    could potentially raise any kind of exception.
 
-   A slash(/) in the signature of a function denotes that the parameters prior
+   A slash (/) in the signature of a function denotes that the parameters prior
    to it are positional-only. For more info, see
    :ref:`the FAQ entry on positional-only parameters <faq-positional-only-arguments>`.
 
@@ -746,6 +751,9 @@ function.
    .. versionchanged:: 3.10
       The *globals*, *locals*, and *eval_str* parameters were added.
 
+   .. versionchanged:: 3.14
+      The *annotation_format* parameter was added.
+
    .. note::
 
       Some callables may not be introspectable in certain implementations of
@@ -838,7 +846,7 @@ function.
       :class:`Signature` objects are also supported by the generic function
       :func:`copy.replace`.
 
-   .. method:: format(*, max_width=None)
+   .. method:: format(*, max_width=None, quote_annotation_strings=True)
 
       Create a string representation of the :class:`Signature` object.
 
@@ -847,8 +855,17 @@ function.
       If the signature is longer than *max_width*,
       all parameters will be on separate lines.
 
+      If *quote_annotation_strings* is False, :term:`annotations <annotation>`
+      in the signature are displayed without opening and closing quotation
+      marks if they are strings. This is useful if the signature was created with the
+      :attr:`~annotationlib.Format.STRING` format or if
+      ``from __future__ import annotations`` was used.
+
       .. versionadded:: 3.13
 
+      .. versionchanged:: 3.14
+         The *unquote_annotations* parameter was added.
+
    .. 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 4d71a24e9cc9cafefb7ae08fbe2e3ddd4243e770..c62a3ca5872eefde0ffb6bb3a053feb22b5c5343 100644 (file)
@@ -281,6 +281,18 @@ http
   (Contributed by Yorik Hansen in :gh:`123430`.)
 
 
+inspect
+-------
+
+* :func:`inspect.signature` takes a new argument *annotation_format* to control
+  the :class:`annotationlib.Format` used for representing annotations.
+  (Contributed by Jelle Zijlstra in :gh:`101552`.)
+
+* :meth:`inspect.Signature.format` takes a new argument *unquote_annotations*.
+  If true, string :term:`annotations <annotation>` are displayed without surrounding quotes.
+  (Contributed by Jelle Zijlstra in :gh:`101552`.)
+
+
 json
 ----
 
@@ -356,6 +368,14 @@ pickle
   of the error.
   (Contributed by Serhiy Storchaka in :gh:`122213`.)
 
+pydoc
+-----
+
+* :term:`Annotations <annotation>` in help output are now usually
+  displayed in a format closer to that in the original source.
+  (Contributed by Jelle Zijlstra in :gh:`101552`.)
+
+
 symtable
 --------
 
index 1763ef640bbe041cd1cb2f76eb3eddb6fdc720f2..0c33c6cc995a03a313e8904a2b598826d576788a 100644 (file)
@@ -140,6 +140,7 @@ __all__ = [
 
 
 import abc
+from annotationlib import Format
 from annotationlib import get_annotations  # re-exported
 import ast
 import dis
@@ -1319,7 +1320,9 @@ def getargvalues(frame):
     args, varargs, varkw = getargs(frame.f_code)
     return ArgInfo(args, varargs, varkw, frame.f_locals)
 
-def formatannotation(annotation, base_module=None):
+def formatannotation(annotation, base_module=None, *, quote_annotation_strings=True):
+    if not quote_annotation_strings and isinstance(annotation, str):
+        return annotation
     if getattr(annotation, '__module__', None) == 'typing':
         def repl(match):
             text = match.group()
@@ -2270,7 +2273,8 @@ def _signature_from_builtin(cls, func, skip_bound_arg=True):
 
 
 def _signature_from_function(cls, func, skip_bound_arg=True,
-                             globals=None, locals=None, eval_str=False):
+                             globals=None, locals=None, eval_str=False,
+                             *, annotation_format=Format.VALUE):
     """Private helper: constructs Signature for the given python function."""
 
     is_duck_function = False
@@ -2296,7 +2300,8 @@ def _signature_from_function(cls, func, skip_bound_arg=True,
     positional = arg_names[:pos_count]
     keyword_only_count = func_code.co_kwonlyargcount
     keyword_only = arg_names[pos_count:pos_count + keyword_only_count]
-    annotations = get_annotations(func, globals=globals, locals=locals, eval_str=eval_str)
+    annotations = get_annotations(func, globals=globals, locals=locals, eval_str=eval_str,
+                                  format=annotation_format)
     defaults = func.__defaults__
     kwdefaults = func.__kwdefaults__
 
@@ -2379,7 +2384,8 @@ def _signature_from_callable(obj, *,
                              globals=None,
                              locals=None,
                              eval_str=False,
-                             sigcls):
+                             sigcls,
+                             annotation_format=Format.VALUE):
 
     """Private helper function to get signature for arbitrary
     callable objects.
@@ -2391,7 +2397,8 @@ def _signature_from_callable(obj, *,
                                 globals=globals,
                                 locals=locals,
                                 sigcls=sigcls,
-                                eval_str=eval_str)
+                                eval_str=eval_str,
+                                annotation_format=annotation_format)
 
     if not callable(obj):
         raise TypeError('{!r} is not a callable object'.format(obj))
@@ -2472,7 +2479,8 @@ def _signature_from_callable(obj, *,
         # of a Python function (Cython functions, for instance), then:
         return _signature_from_function(sigcls, obj,
                                         skip_bound_arg=skip_bound_arg,
-                                        globals=globals, locals=locals, eval_str=eval_str)
+                                        globals=globals, locals=locals, eval_str=eval_str,
+                                        annotation_format=annotation_format)
 
     if _signature_is_builtin(obj):
         return _signature_from_builtin(sigcls, obj,
@@ -2707,13 +2715,17 @@ class Parameter:
         return type(self)(name, kind, default=default, annotation=annotation)
 
     def __str__(self):
+        return self._format()
+
+    def _format(self, *, quote_annotation_strings=True):
         kind = self.kind
         formatted = self._name
 
         # Add annotation and default value
         if self._annotation is not _empty:
-            formatted = '{}: {}'.format(formatted,
-                                       formatannotation(self._annotation))
+            annotation = formatannotation(self._annotation,
+                                          quote_annotation_strings=quote_annotation_strings)
+            formatted = '{}: {}'.format(formatted, annotation)
 
         if self._default is not _empty:
             if self._annotation is not _empty:
@@ -2961,11 +2973,13 @@ class Signature:
 
     @classmethod
     def from_callable(cls, obj, *,
-                      follow_wrapped=True, globals=None, locals=None, eval_str=False):
+                      follow_wrapped=True, globals=None, locals=None, eval_str=False,
+                      annotation_format=Format.VALUE):
         """Constructs Signature for the given callable object."""
         return _signature_from_callable(obj, sigcls=cls,
                                         follow_wrapper_chains=follow_wrapped,
-                                        globals=globals, locals=locals, eval_str=eval_str)
+                                        globals=globals, locals=locals, eval_str=eval_str,
+                                        annotation_format=annotation_format)
 
     @property
     def parameters(self):
@@ -3180,19 +3194,24 @@ class Signature:
     def __str__(self):
         return self.format()
 
-    def format(self, *, max_width=None):
+    def format(self, *, max_width=None, quote_annotation_strings=True):
         """Create a string representation of the Signature object.
 
         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.
+
+        If *quote_annotation_strings* is False, annotations
+        in the signature are displayed without opening and closing quotation
+        marks. This is useful when the signature was created with the
+        STRING format or when ``from __future__ import annotations`` was used.
         """
         result = []
         render_pos_only_separator = False
         render_kw_only_separator = True
         for param in self.parameters.values():
-            formatted = str(param)
+            formatted = param._format(quote_annotation_strings=quote_annotation_strings)
 
             kind = param.kind
 
@@ -3229,16 +3248,19 @@ class Signature:
             rendered = '(\n    {}\n)'.format(',\n    '.join(result))
 
         if self.return_annotation is not _empty:
-            anno = formatannotation(self.return_annotation)
+            anno = formatannotation(self.return_annotation,
+                                    quote_annotation_strings=quote_annotation_strings)
             rendered += ' -> {}'.format(anno)
 
         return rendered
 
 
-def signature(obj, *, follow_wrapped=True, globals=None, locals=None, eval_str=False):
+def signature(obj, *, follow_wrapped=True, globals=None, locals=None, eval_str=False,
+              annotation_format=Format.VALUE):
     """Get a signature object for the passed callable."""
     return Signature.from_callable(obj, follow_wrapped=follow_wrapped,
-                                   globals=globals, locals=locals, eval_str=eval_str)
+                                   globals=globals, locals=locals, eval_str=eval_str,
+                                   annotation_format=annotation_format)
 
 
 class BufferFlags(enum.IntFlag):
index eec7b0770f56cadba878598de75e7f6cd23324a2..c863794ea14ef9aed0134944a03340b4c4ee2932 100644 (file)
@@ -71,6 +71,7 @@ import time
 import tokenize
 import urllib.parse
 import warnings
+from annotationlib import Format
 from collections import deque
 from reprlib import Repr
 from traceback import format_exception_only
@@ -212,12 +213,12 @@ def splitdoc(doc):
 
 def _getargspec(object):
     try:
-        signature = inspect.signature(object)
+        signature = inspect.signature(object, annotation_format=Format.STRING)
         if 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)
+            return signature.format(max_width=max_width, quote_annotation_strings=False)
     except (ValueError, TypeError):
         argspec = getattr(object, '__text_signature__', None)
         if argspec:
diff --git a/Lib/test/test_inspect/inspect_deferred_annotations.py b/Lib/test/test_inspect/inspect_deferred_annotations.py
new file mode 100644 (file)
index 0000000..bb59ef1
--- /dev/null
@@ -0,0 +1,2 @@
+def f(x: undefined):
+    pass
index 2ecb7ec1e26e0ead6178e031ecf7134049aab385..9fa6d23d15f06a8a077e13b6e166efc0c1fb5b2f 100644 (file)
@@ -1,3 +1,4 @@
+from annotationlib import Format, ForwardRef
 import asyncio
 import builtins
 import collections
@@ -22,7 +23,6 @@ import time
 import types
 import tempfile
 import textwrap
-from typing import Unpack
 import unicodedata
 import unittest
 import unittest.mock
@@ -46,6 +46,7 @@ from test import support
 from test.test_inspect import inspect_fodder as mod
 from test.test_inspect import inspect_fodder2 as mod2
 from test.test_inspect import inspect_stringized_annotations
+from test.test_inspect import inspect_deferred_annotations
 
 
 # Functions tested in this suite:
@@ -4622,6 +4623,18 @@ class TestSignatureObject(unittest.TestCase):
             expected_multiline,
         )
 
+    def test_signature_format_unquote(self):
+        def func(x: 'int') -> 'str': ...
+
+        self.assertEqual(
+            inspect.signature(func).format(),
+            "(x: 'int') -> 'str'"
+        )
+        self.assertEqual(
+            inspect.signature(func).format(quote_annotation_strings=False),
+            "(x: int) -> str"
+        )
+
     def test_signature_replace_parameters(self):
         def test(a, b) -> 42:
             pass
@@ -4854,6 +4867,26 @@ class TestSignatureObject(unittest.TestCase):
                             par('b', PORK, annotation=tuple),
                         )))
 
+    def test_signature_annotation_format(self):
+        ida = inspect_deferred_annotations
+        sig = inspect.Signature
+        par = inspect.Parameter
+        PORK = inspect.Parameter.POSITIONAL_OR_KEYWORD
+        for signature_func in (inspect.signature, inspect.Signature.from_callable):
+            with self.subTest(signature_func=signature_func):
+                self.assertEqual(
+                    signature_func(ida.f, annotation_format=Format.STRING),
+                    sig([par("x", PORK, annotation="undefined")])
+                )
+                self.assertEqual(
+                    signature_func(ida.f, annotation_format=Format.FORWARDREF),
+                    sig([par("x", PORK, annotation=ForwardRef("undefined"))])
+                )
+                with self.assertRaisesRegex(NameError, "undefined"):
+                    signature_func(ida.f, annotation_format=Format.VALUE)
+                with self.assertRaisesRegex(NameError, "undefined"):
+                    signature_func(ida.f)
+
     def test_signature_none_annotation(self):
         class funclike:
             # Has to be callable, and have correct
index 776e02f41a1cec3df40aad7e3d9aca11a8eea03d..2a4d3ab73db608e4fc69f49c83458e33e7bf7b1b 100644 (file)
@@ -1073,7 +1073,7 @@ class B(A)
 
 class A(builtins.object)
  |  A(
- |      arg1: collections.abc.Callable[[int, int, int], str],
+ |      arg1: Callable[[int, int, int], str],
  |      arg2: Literal['some value', 'other value'],
  |      arg3: Annotated[int, 'some docs about this type']
  |  ) -> None
@@ -1082,7 +1082,7 @@ class A(builtins.object)
  |
  |  __init__(
  |      self,
- |      arg1: collections.abc.Callable[[int, int, int], str],
+ |      arg1: Callable[[int, int, int], str],
  |      arg2: Literal['some value', 'other value'],
  |      arg3: Annotated[int, 'some docs about this type']
  |  ) -> None
@@ -1109,7 +1109,7 @@ class A(builtins.object)
         self.assertEqual(doc, '''Python Library Documentation: function func in module %s
 
 func(
-    arg1: collections.abc.Callable[[typing.Annotated[int, 'Some doc']], str],
+    arg1: Callable[[Annotated[int, 'Some doc']], str],
     arg2: Literal[1, 2, 3, 4, 5, 6, 7, 8]
 ) -> Annotated[int, 'Some other']
 ''' % __name__)
@@ -1394,8 +1394,8 @@ class TestDescriptions(unittest.TestCase):
         T = typing.TypeVar('T')
         class C(typing.Generic[T], typing.Mapping[int, str]): ...
         self.assertEqual(pydoc.render_doc(foo).splitlines()[-1],
-                         'f\x08fo\x08oo\x08o(data: List[Any], x: int)'
-                         ' -> Iterator[Tuple[int, Any]]')
+                         'f\x08fo\x08oo\x08o(data: typing.List[typing.Any], x: int)'
+                         ' -> typing.Iterator[typing.Tuple[int, typing.Any]]')
         self.assertEqual(pydoc.render_doc(C).splitlines()[2],
                          'class C\x08C(collections.abc.Mapping, typing.Generic)')
 
diff --git a/Misc/NEWS.d/next/Library/2024-09-27-06-39-32.gh-issue-101552.xYkzag.rst b/Misc/NEWS.d/next/Library/2024-09-27-06-39-32.gh-issue-101552.xYkzag.rst
new file mode 100644 (file)
index 0000000..913a84d
--- /dev/null
@@ -0,0 +1,4 @@
+Add an *annoation_format* parameter to :func:`inspect.signature`. Add an
+*quote_annotation_strings* parameter to :meth:`inspect.Signature.format`. Use the
+new functionality to improve the display of annotations in signatures in
+:mod:`pydoc`. Patch by Jelle Zijlstra.