gh-101552: Allow pydoc to display signatures in source format (#124669)

Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
This commit is contained in:
Jelle Zijlstra 2024-10-08 22:03:53 -07:00 committed by GitHub
parent b502573f7f
commit 78406382c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 126 additions and 27 deletions

View File

@ -694,7 +694,7 @@ and its return annotation. To retrieve a :class:`!Signature` object,
use the :func:`!signature` use the :func:`!signature`
function. 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*: Return a :class:`Signature` object for the given *callable*:
@ -725,7 +725,12 @@ function.
*globals*, *locals*, and *eval_str* parameters are passed *globals*, *locals*, and *eval_str* parameters are passed
into :func:`!annotationlib.get_annotations` when resolving the into :func:`!annotationlib.get_annotations` when resolving the
annotations; see the documentation for :func:`!annotationlib.get_annotations` 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 Raises :exc:`ValueError` if no signature can be provided, and
:exc:`TypeError` if that type of object is not supported. Also, :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` the ``eval()`` call(s) to un-stringize the annotations in :func:`annotationlib.get_annotations`
could potentially raise any kind of exception. 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 to it are positional-only. For more info, see
:ref:`the FAQ entry on positional-only parameters <faq-positional-only-arguments>`. :ref:`the FAQ entry on positional-only parameters <faq-positional-only-arguments>`.
@ -746,6 +751,9 @@ function.
.. versionchanged:: 3.10 .. versionchanged:: 3.10
The *globals*, *locals*, and *eval_str* parameters were added. The *globals*, *locals*, and *eval_str* parameters were added.
.. versionchanged:: 3.14
The *annotation_format* parameter was added.
.. note:: .. note::
Some callables may not be introspectable in certain implementations of 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 :class:`Signature` objects are also supported by the generic function
:func:`copy.replace`. :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. Create a string representation of the :class:`Signature` object.
@ -847,8 +855,17 @@ function.
If the signature is longer than *max_width*, If the signature is longer than *max_width*,
all parameters will be on separate lines. 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 .. 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) .. 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 Return a :class:`Signature` (or its subclass) object for a given callable

View File

@ -281,6 +281,18 @@ http
(Contributed by Yorik Hansen in :gh:`123430`.) (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 json
---- ----
@ -356,6 +368,14 @@ pickle
of the error. of the error.
(Contributed by Serhiy Storchaka in :gh:`122213`.) (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 symtable
-------- --------

View File

@ -140,6 +140,7 @@ __all__ = [
import abc import abc
from annotationlib import Format
from annotationlib import get_annotations # re-exported from annotationlib import get_annotations # re-exported
import ast import ast
import dis import dis
@ -1319,7 +1320,9 @@ def getargvalues(frame):
args, varargs, varkw = getargs(frame.f_code) args, varargs, varkw = getargs(frame.f_code)
return ArgInfo(args, varargs, varkw, frame.f_locals) 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': if getattr(annotation, '__module__', None) == 'typing':
def repl(match): def repl(match):
text = match.group() 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, 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.""" """Private helper: constructs Signature for the given python function."""
is_duck_function = False is_duck_function = False
@ -2296,7 +2300,8 @@ def _signature_from_function(cls, func, skip_bound_arg=True,
positional = arg_names[:pos_count] positional = arg_names[:pos_count]
keyword_only_count = func_code.co_kwonlyargcount keyword_only_count = func_code.co_kwonlyargcount
keyword_only = arg_names[pos_count:pos_count + keyword_only_count] 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__ defaults = func.__defaults__
kwdefaults = func.__kwdefaults__ kwdefaults = func.__kwdefaults__
@ -2379,7 +2384,8 @@ def _signature_from_callable(obj, *,
globals=None, globals=None,
locals=None, locals=None,
eval_str=False, eval_str=False,
sigcls): sigcls,
annotation_format=Format.VALUE):
"""Private helper function to get signature for arbitrary """Private helper function to get signature for arbitrary
callable objects. callable objects.
@ -2391,7 +2397,8 @@ def _signature_from_callable(obj, *,
globals=globals, globals=globals,
locals=locals, locals=locals,
sigcls=sigcls, sigcls=sigcls,
eval_str=eval_str) eval_str=eval_str,
annotation_format=annotation_format)
if not callable(obj): if not callable(obj):
raise TypeError('{!r} is not a callable object'.format(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: # of a Python function (Cython functions, for instance), then:
return _signature_from_function(sigcls, obj, return _signature_from_function(sigcls, obj,
skip_bound_arg=skip_bound_arg, 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): if _signature_is_builtin(obj):
return _signature_from_builtin(sigcls, obj, return _signature_from_builtin(sigcls, obj,
@ -2707,13 +2715,17 @@ class Parameter:
return type(self)(name, kind, default=default, annotation=annotation) return type(self)(name, kind, default=default, annotation=annotation)
def __str__(self): def __str__(self):
return self._format()
def _format(self, *, quote_annotation_strings=True):
kind = self.kind kind = self.kind
formatted = self._name formatted = self._name
# Add annotation and default value # Add annotation and default value
if self._annotation is not _empty: if self._annotation is not _empty:
formatted = '{}: {}'.format(formatted, annotation = formatannotation(self._annotation,
formatannotation(self._annotation)) quote_annotation_strings=quote_annotation_strings)
formatted = '{}: {}'.format(formatted, annotation)
if self._default is not _empty: if self._default is not _empty:
if self._annotation is not _empty: if self._annotation is not _empty:
@ -2961,11 +2973,13 @@ class Signature:
@classmethod @classmethod
def from_callable(cls, obj, *, 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.""" """Constructs Signature for the given callable object."""
return _signature_from_callable(obj, sigcls=cls, return _signature_from_callable(obj, sigcls=cls,
follow_wrapper_chains=follow_wrapped, 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 @property
def parameters(self): def parameters(self):
@ -3180,19 +3194,24 @@ class Signature:
def __str__(self): def __str__(self):
return self.format() 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. """Create a string representation of the Signature object.
If *max_width* integer is passed, If *max_width* integer is passed,
signature will try to fit into the *max_width*. signature will try to fit into the *max_width*.
If signature is longer than *max_width*, If signature is longer than *max_width*,
all parameters will be on separate lines. 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 = [] result = []
render_pos_only_separator = False render_pos_only_separator = False
render_kw_only_separator = True render_kw_only_separator = True
for param in self.parameters.values(): for param in self.parameters.values():
formatted = str(param) formatted = param._format(quote_annotation_strings=quote_annotation_strings)
kind = param.kind kind = param.kind
@ -3229,16 +3248,19 @@ class Signature:
rendered = '(\n {}\n)'.format(',\n '.join(result)) rendered = '(\n {}\n)'.format(',\n '.join(result))
if self.return_annotation is not _empty: 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) rendered += ' -> {}'.format(anno)
return rendered 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.""" """Get a signature object for the passed callable."""
return Signature.from_callable(obj, follow_wrapped=follow_wrapped, 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): class BufferFlags(enum.IntFlag):

View File

@ -71,6 +71,7 @@ import time
import tokenize import tokenize
import urllib.parse import urllib.parse
import warnings import warnings
from annotationlib import Format
from collections import deque from collections import deque
from reprlib import Repr from reprlib import Repr
from traceback import format_exception_only from traceback import format_exception_only
@ -212,12 +213,12 @@ def splitdoc(doc):
def _getargspec(object): def _getargspec(object):
try: try:
signature = inspect.signature(object) signature = inspect.signature(object, annotation_format=Format.STRING)
if signature: if signature:
name = getattr(object, '__name__', '') name = getattr(object, '__name__', '')
# <lambda> function are always single-line and should not be formatted # <lambda> function are always single-line and should not be formatted
max_width = (80 - len(name)) if name != '<lambda>' else None 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): except (ValueError, TypeError):
argspec = getattr(object, '__text_signature__', None) argspec = getattr(object, '__text_signature__', None)
if argspec: if argspec:

View File

@ -0,0 +1,2 @@
def f(x: undefined):
pass

View File

@ -1,3 +1,4 @@
from annotationlib import Format, ForwardRef
import asyncio import asyncio
import builtins import builtins
import collections import collections
@ -22,7 +23,6 @@ import time
import types import types
import tempfile import tempfile
import textwrap import textwrap
from typing import Unpack
import unicodedata import unicodedata
import unittest import unittest
import unittest.mock 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_fodder as mod
from test.test_inspect import inspect_fodder2 as mod2 from test.test_inspect import inspect_fodder2 as mod2
from test.test_inspect import inspect_stringized_annotations from test.test_inspect import inspect_stringized_annotations
from test.test_inspect import inspect_deferred_annotations
# Functions tested in this suite: # Functions tested in this suite:
@ -4622,6 +4623,18 @@ class TestSignatureObject(unittest.TestCase):
expected_multiline, 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_signature_replace_parameters(self):
def test(a, b) -> 42: def test(a, b) -> 42:
pass pass
@ -4854,6 +4867,26 @@ class TestSignatureObject(unittest.TestCase):
par('b', PORK, annotation=tuple), 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): def test_signature_none_annotation(self):
class funclike: class funclike:
# Has to be callable, and have correct # Has to be callable, and have correct

View File

@ -1073,7 +1073,7 @@ class B(A)
class A(builtins.object) class A(builtins.object)
| A( | A(
| arg1: collections.abc.Callable[[int, int, int], str], | arg1: Callable[[int, int, int], str],
| arg2: Literal['some value', 'other value'], | arg2: Literal['some value', 'other value'],
| arg3: Annotated[int, 'some docs about this type'] | arg3: Annotated[int, 'some docs about this type']
| ) -> None | ) -> None
@ -1082,7 +1082,7 @@ class A(builtins.object)
| |
| __init__( | __init__(
| self, | self,
| arg1: collections.abc.Callable[[int, int, int], str], | arg1: Callable[[int, int, int], str],
| arg2: Literal['some value', 'other value'], | arg2: Literal['some value', 'other value'],
| arg3: Annotated[int, 'some docs about this type'] | arg3: Annotated[int, 'some docs about this type']
| ) -> None | ) -> None
@ -1109,7 +1109,7 @@ class A(builtins.object)
self.assertEqual(doc, '''Python Library Documentation: function func in module %s self.assertEqual(doc, '''Python Library Documentation: function func in module %s
func( 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] arg2: Literal[1, 2, 3, 4, 5, 6, 7, 8]
) -> Annotated[int, 'Some other'] ) -> Annotated[int, 'Some other']
''' % __name__) ''' % __name__)
@ -1394,8 +1394,8 @@ class TestDescriptions(unittest.TestCase):
T = typing.TypeVar('T') T = typing.TypeVar('T')
class C(typing.Generic[T], typing.Mapping[int, str]): ... class C(typing.Generic[T], typing.Mapping[int, str]): ...
self.assertEqual(pydoc.render_doc(foo).splitlines()[-1], self.assertEqual(pydoc.render_doc(foo).splitlines()[-1],
'f\x08fo\x08oo\x08o(data: List[Any], x: int)' 'f\x08fo\x08oo\x08o(data: typing.List[typing.Any], x: int)'
' -> Iterator[Tuple[int, Any]]') ' -> typing.Iterator[typing.Tuple[int, typing.Any]]')
self.assertEqual(pydoc.render_doc(C).splitlines()[2], self.assertEqual(pydoc.render_doc(C).splitlines()[2],
'class C\x08C(collections.abc.Mapping, typing.Generic)') 'class C\x08C(collections.abc.Mapping, typing.Generic)')

View File

@ -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.