You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
201 lines
7.0 KiB
201 lines
7.0 KiB
8 months ago
|
"""Preserve function defaults.
|
||
|
|
||
|
Preserve the default argument values of function signatures in source code
|
||
|
and keep them not evaluated for readability.
|
||
|
"""
|
||
|
|
||
|
from __future__ import annotations
|
||
|
|
||
|
import ast
|
||
|
import inspect
|
||
|
import types
|
||
|
import warnings
|
||
|
from typing import TYPE_CHECKING
|
||
|
|
||
|
import sphinx
|
||
|
from sphinx.deprecation import RemovedInSphinx90Warning
|
||
|
from sphinx.locale import __
|
||
|
from sphinx.pycode.ast import unparse as ast_unparse
|
||
|
from sphinx.util import logging
|
||
|
|
||
|
if TYPE_CHECKING:
|
||
|
from typing import Any
|
||
|
|
||
|
from sphinx.application import Sphinx
|
||
|
from sphinx.util.typing import ExtensionMetadata
|
||
|
|
||
|
logger = logging.getLogger(__name__)
|
||
|
_LAMBDA_NAME = (lambda: None).__name__
|
||
|
|
||
|
|
||
|
class DefaultValue:
|
||
|
def __init__(self, name: str) -> None:
|
||
|
self.name = name
|
||
|
|
||
|
def __repr__(self) -> str:
|
||
|
return self.name
|
||
|
|
||
|
|
||
|
def get_function_def(obj: Any) -> ast.FunctionDef | None:
|
||
|
"""Get FunctionDef object from living object.
|
||
|
|
||
|
This tries to parse original code for living object and returns
|
||
|
AST node for given *obj*.
|
||
|
"""
|
||
|
warnings.warn('sphinx.ext.autodoc.preserve_defaults.get_function_def is'
|
||
|
' deprecated and scheduled for removal in Sphinx 9.'
|
||
|
' Use sphinx.ext.autodoc.preserve_defaults._get_arguments() to'
|
||
|
' extract AST arguments objects from a lambda or regular'
|
||
|
' function.', RemovedInSphinx90Warning, stacklevel=2)
|
||
|
|
||
|
try:
|
||
|
source = inspect.getsource(obj)
|
||
|
if source.startswith((' ', '\t')):
|
||
|
# 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[attr-defined]
|
||
|
else:
|
||
|
module = ast.parse(source)
|
||
|
return module.body[0] # type: ignore[return-value]
|
||
|
except (OSError, TypeError): # failed to load source code
|
||
|
return None
|
||
|
|
||
|
|
||
|
def _get_arguments(obj: Any, /) -> ast.arguments | None:
|
||
|
"""Parse 'ast.arguments' from an object.
|
||
|
|
||
|
This tries to parse the original code for an object and returns
|
||
|
an 'ast.arguments' node.
|
||
|
"""
|
||
|
try:
|
||
|
source = inspect.getsource(obj)
|
||
|
if source.startswith((' ', '\t')):
|
||
|
# 'obj' is in some indented block.
|
||
|
module = ast.parse('if True:\n' + source)
|
||
|
subject = module.body[0].body[0] # type: ignore[attr-defined]
|
||
|
else:
|
||
|
module = ast.parse(source)
|
||
|
subject = module.body[0]
|
||
|
except (OSError, TypeError):
|
||
|
# bail; failed to load source for 'obj'.
|
||
|
return None
|
||
|
except SyntaxError:
|
||
|
if _is_lambda(obj):
|
||
|
# Most likely a multi-line arising from detecting a lambda, e.g.:
|
||
|
#
|
||
|
# class Egg:
|
||
|
# 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 is renamed by the user).
|
||
|
raise
|
||
|
|
||
|
return _get_arguments_inner(subject)
|
||
|
|
||
|
|
||
|
def _is_lambda(x: Any, /) -> bool:
|
||
|
return isinstance(x, types.LambdaType) and x.__name__ == _LAMBDA_NAME
|
||
|
|
||
|
|
||
|
def _get_arguments_inner(x: Any, /) -> 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_inner(x.value)
|
||
|
return None
|
||
|
|
||
|
|
||
|
def get_default_value(lines: list[str], position: ast.AST) -> str | None:
|
||
|
try:
|
||
|
if position.lineno == position.end_lineno:
|
||
|
line = lines[position.lineno - 1]
|
||
|
return line[position.col_offset:position.end_col_offset]
|
||
|
else:
|
||
|
# multiline value is not supported now
|
||
|
return None
|
||
|
except (AttributeError, IndexError):
|
||
|
return None
|
||
|
|
||
|
|
||
|
def update_defvalue(app: Sphinx, obj: Any, bound_method: bool) -> None:
|
||
|
"""Update defvalue info of *obj* using type_comments."""
|
||
|
if not app.config.autodoc_preserve_defaults:
|
||
|
return
|
||
|
|
||
|
try:
|
||
|
lines = inspect.getsource(obj).splitlines()
|
||
|
if lines[0].startswith((' ', '\t')):
|
||
|
# insert a dummy line to follow what _get_arguments() does.
|
||
|
lines.insert(0, '')
|
||
|
except (OSError, TypeError):
|
||
|
lines = []
|
||
|
|
||
|
try:
|
||
|
args = _get_arguments(obj)
|
||
|
except SyntaxError:
|
||
|
return
|
||
|
if args is None:
|
||
|
# If the object is a built-in, we won't be always able to recover
|
||
|
# the function definition and its arguments. This happens if *obj*
|
||
|
# is the `__init__` method generated automatically for dataclasses.
|
||
|
return
|
||
|
|
||
|
if not args.defaults and not args.kw_defaults:
|
||
|
return
|
||
|
|
||
|
try:
|
||
|
if bound_method and inspect.ismethod(obj) and hasattr(obj, '__func__'):
|
||
|
sig = inspect.signature(obj.__func__)
|
||
|
else:
|
||
|
sig = inspect.signature(obj)
|
||
|
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:
|
||
|
if param.kind == param.KEYWORD_ONLY:
|
||
|
# Consume kw_defaults for kwonly args
|
||
|
kw_defaults.pop(0)
|
||
|
else:
|
||
|
if param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD):
|
||
|
default = defaults.pop(0)
|
||
|
value = get_default_value(lines, default)
|
||
|
if value is None:
|
||
|
value = ast_unparse(default)
|
||
|
parameters[i] = param.replace(default=DefaultValue(value))
|
||
|
else:
|
||
|
default = kw_defaults.pop(0) # type: ignore[assignment]
|
||
|
value = get_default_value(lines, default)
|
||
|
if value is None:
|
||
|
value = ast_unparse(default)
|
||
|
parameters[i] = param.replace(default=DefaultValue(value))
|
||
|
|
||
|
sig = sig.replace(parameters=parameters)
|
||
|
try:
|
||
|
obj.__signature__ = sig
|
||
|
except AttributeError:
|
||
|
# __signature__ can't be set directly on bound methods.
|
||
|
obj.__dict__['__signature__'] = sig
|
||
|
except (AttributeError, TypeError):
|
||
|
# Failed to update signature (e.g. built-in or extension types).
|
||
|
# For user-defined functions, "obj" may not have __dict__,
|
||
|
# e.g. when decorated with a class that defines __slots__.
|
||
|
# In this case, we can't set __signature__.
|
||
|
return
|
||
|
except NotImplementedError as exc: # failed to ast_unparse()
|
||
|
logger.warning(__("Failed to parse a default argument value for %r: %s"), obj, exc)
|
||
|
|
||
|
|
||
|
def setup(app: Sphinx) -> ExtensionMetadata:
|
||
|
app.add_config_value('autodoc_preserve_defaults', False, 'env')
|
||
|
app.connect('autodoc-before-process-signature', update_defvalue)
|
||
|
|
||
|
return {
|
||
|
'version': sphinx.__display_version__,
|
||
|
'parallel_read_safe': True,
|
||
|
}
|