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
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 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 @@ -722,18 +722,20 @@ function.
``from __future__ import annotations`` was used), :func:`signature` will
attempt to automatically un-stringize the annotations using
:func:`annotationlib.get_annotations`. The
*globals*, *locals*, and *eval_str* parameters are passed
*globals*, *locals*, *eval_str*, and *annotation_format* parameters are passed
Copy link
Contributor

Choose a reason for hiding this comment

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

It's a minor thing, but: globals, locals, and eval_str are passed in to parameters with the same name when calling get_annotations. But annotation_format is passed in to a parameter with a different name (just format). It might be nice to explicitly tell that to the user.

I realize the documentation doesn't explicitly detail where these parameters go, so this would be all-new text. And when I try it in my head it always comes across as clumsy. So... should we even bother to try? Would anybody be confused if we didn't bother to explain it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point, I will add a separate sentence about the annotation_format parameter.

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. 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 +748,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 +843,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, unquote_annotations=False)
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't feel like a totally correct use of "unquote" to me... Maybe the parameter could be called remove_annotation_quotes instead of unquote_annotation...? I think that might also reflect slightly better the fact that not all annotations will necessarily be quoted at all.

Copy link
Member Author

Choose a reason for hiding this comment

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

We use "unquote" in various other places (e.g., https://docs.python.org/3.13/library/csv.html#csv.QUOTE_NOTNULL); I feel it's more clear and concise

Copy link
Member

Choose a reason for hiding this comment

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

"unquoted" is used in https://docs.python.org/3.13/library/csv.html#csv.QUOTE_NOTNULL as an adjective rather than a verb. I think it makes sense to talk about something being "unquoted" but less sense to talk about "unquoting" something.

Copy link
Contributor

Choose a reason for hiding this comment

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

"dequote"?

Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm reading the code correctly, if this is false (the default) we use repr on annotation values that are strings, and if it's true we skip repr only for strings. So this is a passive operation--if the value is true, we don't do something, and when it's false we do do something. I think that's why this is awkward.

I suggest the clearest way to express this is to flip it. Negate the boolean and rename it quote_annotations=True. If it's true (the default) we quote annotations, and if it's false we don't. I might even go with the wordier quote_annotation_strings=True, as we're only allowing the user to control quoting or not-quoting the annotations when they're strings. But I don't have a strong opinion about that.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea, changing that.


Create a string representation of the :class:`Signature` object.

Expand All @@ -847,8 +852,17 @@ function.
If the signature is longer than *max_width*,
all parameters will be on separate lines.

If *unquote_annotations* is True, :term:`annotations <annotation>`
in the signature are displayed without opening and closing quotation
marks. 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 @@ -272,6 +272,18 @@ module allow the browser to apply its default dark mode.
(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 @@ -347,6 +359,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
51 changes: 36 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 @@ -1317,7 +1318,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, *, unquote_annotations=False):
if unquote_annotations and isinstance(annotation, str):
return annotation
if getattr(annotation, '__module__', None) == 'typing':
def repl(match):
text = match.group()
Expand Down Expand Up @@ -2268,7 +2271,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 @@ -2294,7 +2298,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 @@ -2377,7 +2382,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 @@ -2389,7 +2395,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 @@ -2478,7 +2485,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 @@ -2713,13 +2721,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, *, unquote_annotations=False):
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,
unquote_annotations=unquote_annotations)
formatted = '{}: {}'.format(formatted, annotation)

if self._default is not _empty:
if self._annotation is not _empty:
Expand Down Expand Up @@ -2967,11 +2979,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 @@ -3186,19 +3200,24 @@ def __repr__(self):
def __str__(self):
return self.format()

def format(self, *, max_width=None):
def format(self, *, max_width=None, unquote_annotations=False):
"""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 *unquote_annotations* is True, 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(unquote_annotations=unquote_annotations)

kind = param.kind

Expand Down Expand Up @@ -3235,16 +3254,18 @@ 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, unquote_annotations=unquote_annotations)
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, unquote_annotations=True)
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 @@ -4565,6 +4566,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(unquote_annotations=True),
"(x: int) -> str"
)

def test_signature_replace_parameters(self):
def test(a, b) -> 42:
pass
Expand Down Expand Up @@ -4797,6 +4810,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 @@ -1065,7 +1065,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 @@ -1074,7 +1074,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 @@ -1101,7 +1101,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 @@ -1386,8 +1386,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 a *format* parameter to :func:`inspect.signature`. Add an
*unquote_annotations* 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