Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Oct 9, 2024
25 changes: 21 additions & 4 deletions Doc/library/inspect.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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*:

Expand Down Expand Up @@ -725,15 +725,20 @@ 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,
if the annotations are stringized, and *eval_str* is not false,
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>`.

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----

Expand Down Expand Up @@ -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
--------

Expand Down
52 changes: 37 additions & 15 deletions Lib/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@


import abc
from annotationlib import Format
from annotationlib import get_annotations # re-exported
import ast
import dis
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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__

Expand Down Expand Up @@ -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.
Expand All @@ -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))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2707,13 +2715,17 @@ def replace(self, *, name=_void, kind=_void,
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:
Expand Down Expand Up @@ -2961,11 +2973,13 @@ def __init__(self, parameters=None, *, return_annotation=_empty,

@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):
Expand Down Expand Up @@ -3180,19 +3194,24 @@ def __repr__(self):
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

Expand Down Expand Up @@ -3229,16 +3248,19 @@ def format(self, *, max_width=None):
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):
Expand Down
5 changes: 3 additions & 2 deletions Lib/pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class or function within a module or module in a package. If the
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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions Lib/test/test_inspect/inspect_deferred_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def f(x: undefined):
pass
35 changes: 34 additions & 1 deletion Lib/test/test_inspect/test_inspect.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from annotationlib import Format, ForwardRef
import asyncio
import builtins
import collections
Expand All @@ -22,7 +23,6 @@
import types
import tempfile
import textwrap
from typing import Unpack
import unicodedata
import unittest
import unittest.mock
Expand All @@ -46,6 +46,7 @@
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:
Expand Down Expand Up @@ -4622,6 +4623,18 @@ def func(
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
Expand Down Expand Up @@ -4854,6 +4867,26 @@ def test_signature_eval_str(self):
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐷

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
Expand Down
10 changes: 5 additions & 5 deletions Lib/test/test_pydoc/test_pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,7 +1073,7 @@ def __init__(self,

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
Expand All @@ -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
Expand All @@ -1109,7 +1109,7 @@ def func(
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__)
Expand Down Expand Up @@ -1394,8 +1394,8 @@ def foo(data: typing.List[typing.Any],
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)')

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Loading