Skip to content

Commit

Permalink
Fix sphinx.ext.autodoc.preserve_defaults.
Browse files Browse the repository at this point in the history
- Add support for ``async def`` functions.
- Add support for ``lambda`` functions.
- Rename ``get_function_def`` to ``get_arguments`` and update its
  signature accordingly. Previously, ``get_function_def`` incorrectly
  extracted ``ast.FunctionDef`` from an arbitrary object.
- Silently suppress syntax errors arising from multi-line parsing.
  • Loading branch information
picnixz committed Aug 3, 2023
1 parent fbd834f commit 835254a
Show file tree
Hide file tree
Showing 3 changed files with 113 additions and 18 deletions.
61 changes: 43 additions & 18 deletions sphinx/ext/autodoc/preserve_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import ast
import inspect
from types import LambdaType
from typing import Any

import sphinx
Expand All @@ -27,8 +28,15 @@ def __repr__(self) -> str:
return self.name


def get_function_def(obj: Any) -> ast.FunctionDef | None:
"""Get FunctionDef object from living object.
_LAMBDA_NAME = (lambda: None).__name__


def _islambda(v):
return isinstance(v, LambdaType) and v.__name__ == _LAMBDA_NAME


def get_arguments(obj: Any) -> ast.arguments | None:
"""Get ast.arguments object from living object.
This tries to parse original code for living object and returns
AST node for given *obj*.
"""
Expand All @@ -38,12 +46,35 @@ def get_function_def(obj: Any) -> ast.FunctionDef | None:
# subject is placed inside class or block. To read its docstring,
# this adds if-block before the declaration.
module = ast.parse('if True:\n' + source)
return module.body[0].body[0] # type: ignore
subject = module.body[0].body[0]
else:
module = ast.parse(source)
return module.body[0] # type: ignore
subject = module.body[0]
except (OSError, TypeError): # failed to load source code
return None
except SyntaxError:
if _islambda(obj):
# most likely a multi-line arising from detecting a lambda, e.g.:
#
# class Foo:
# x = property(
# lambda self: 1, doc="..."))
return None

# Other syntax errors that are not due to the fact that we are
# documenting a lambda function are propagated (in particular,
# if a lambda function is renamed by the user, the SyntaxError is
# propagated).
raise

def _get_arguments(x: ast.AST) -> ast.arguments | None:
if isinstance(x, (ast.AsyncFunctionDef, ast.FunctionDef, ast.Lambda)):
return x.args
if isinstance(x, (ast.Assign, ast.AnnAssign)):
return _get_arguments(x.value)
return None

return _get_arguments(subject)


def get_default_value(lines: list[str], position: ast.AST) -> str | None:
Expand All @@ -66,28 +97,22 @@ def update_defvalue(app: Sphinx, obj: Any, bound_method: bool) -> None:
try:
lines = inspect.getsource(obj).splitlines()
if lines[0].startswith((' ', r'\t')):
lines.insert(0, '') # insert a dummy line to follow what get_function_def() does.
lines.insert(0, '') # insert a dummy line to follow what get_arguments() does.
except (OSError, TypeError):
lines = []

try:
function = get_function_def(obj)
if function is None:
args = get_arguments(obj)
if args is None:
# If the object is a built-in, we won't be always able to recover
# the function definition. This may happen for instance if *obj*
# is a dataclass, in which case *function* is None.
# the function definition and its arguments. This happens if *obj*
# is the `__init__` method generated automatically for dataclasses.
return

# Although get_function_def() is expected to return ast.FunctionDef,
# it sometimes returns ast.ClassDef objects which do not have an
# "args" field. In the future, get_function_def() should be accurately
# modified so that it always return an ast.FunctionDef object or None,
# but for now, we will catch AttributeError instead and silently ignore
# them.
if function.args.defaults or function.args.kw_defaults:
if args.defaults or args.kw_defaults:
sig = inspect.signature(obj)
defaults = list(function.args.defaults)
kw_defaults = list(function.args.kw_defaults)
defaults = list(args.defaults)
kw_defaults = list(args.kw_defaults)
parameters = list(sig.parameters.values())
for i, param in enumerate(parameters):
if param.default is param.empty:
Expand Down
28 changes: 28 additions & 0 deletions tests/roots/test-ext-autodoc/target/preserve_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,31 @@ def clsmeth(cls, name: str = CONSTANT, sentinel: Any = SENTINEL,
now: datetime = datetime.now(), color: int = 0xFFFFFF,
*, kwarg1, kwarg2 = 0xFFFFFF) -> None:
"""docstring"""


get_sentinel = lambda custom=SENTINEL: custom
"""docstring"""


class MultiLine:
"""docstring"""

# The properties will raise a silent SyntaxError because "lambda self: 1"
# will be detected as a function to update the default values of. However,
# only prop3 will not fail because it's on a single line whereas the others
# will fail to parse.

prop1 = property(
lambda self: 1, doc="docstring")

prop2 = property(
lambda self: 2, doc="docstring"
)

prop3 = property(lambda self: 3, doc="docstring")

prop4 = (property
(lambda self: 4, doc="docstring"))

prop5 = property\
(lambda self: 5, doc="docstring")
42 changes: 42 additions & 0 deletions tests/test_ext_autodoc_preserve_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,53 @@ def test_preserve_defaults(app):
' docstring',
'',
'',
'.. py:class:: MultiLine()',
' :module: target.preserve_defaults',
'',
' docstring',
'',
'',
' .. py:property:: MultiLine.prop1',
' :module: target.preserve_defaults',
'',
' docstring',
'',
'',
' .. py:property:: MultiLine.prop2',
' :module: target.preserve_defaults',
'',
' docstring',
'',
'',
' .. py:property:: MultiLine.prop3',
' :module: target.preserve_defaults',
'',
' docstring',
'',
'',
' .. py:property:: MultiLine.prop4',
' :module: target.preserve_defaults',
'',
' docstring',
'',
'',
' .. py:property:: MultiLine.prop5',
' :module: target.preserve_defaults',
'',
' docstring',
'',
'',
'.. py:function:: foo(name: str = CONSTANT, sentinel: ~typing.Any = SENTINEL, '
'now: ~datetime.datetime = datetime.now(), color: int = %s, *, kwarg1, '
'kwarg2=%s) -> None' % (color, color),
' :module: target.preserve_defaults',
'',
' docstring',
'',
'',
'.. py:function:: get_sentinel(custom=SENTINEL)',
' :module: target.preserve_defaults',
'',
' docstring',
'',
]

0 comments on commit 835254a

Please sign in to comment.