diff --git a/CHANGES b/CHANGES index 84127da87fe..96d66ab268a 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,10 @@ Features added info-field-list * #8514: autodoc: Default values of overloaded functions are taken from actual implementation if they're ellipsis +* #759: autodoc: Add a new extension ``sphinx.ext.autodoc.preserve_defaults``. + It preserves the default argument values of function signatures in source code + and keep them not evaluated for readability. This is an experimental + extension and it will be integrated into autodoc core in Sphinx-4.0 * #8619: html: kbd role generates customizable HTML tags for compound keys * #8634: html: Allow to change the order of JS/CSS via ``priority`` parameter for :meth:`Sphinx.add_js_file()` and :meth:`Sphinx.add_css_file()` diff --git a/sphinx/ext/autodoc/preserve_defaults.py b/sphinx/ext/autodoc/preserve_defaults.py new file mode 100644 index 00000000000..36ef3518ce5 --- /dev/null +++ b/sphinx/ext/autodoc/preserve_defaults.py @@ -0,0 +1,82 @@ +""" + sphinx.ext.autodoc.preserve_defaults + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Preserve the default argument values of function signatures in source code + and keep them not evaluated for readability. + + :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +import ast +import inspect +from typing import Any, Dict + +from sphinx.application import Sphinx +from sphinx.locale import __ +from sphinx.pycode.ast import parse as ast_parse +from sphinx.pycode.ast import unparse as ast_unparse +from sphinx.util import logging + +logger = logging.getLogger(__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: + """Get FunctionDef object from living object. + This tries to parse original code for living object and returns + AST node for given *obj*. + """ + try: + source = inspect.getsource(obj) + if source.startswith((' ', r'\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 + else: + module = ast_parse(source) + return module.body[0] # type: ignore + except (OSError, TypeError): # failed to load source code + return None + + +def update_defvalue(app: Sphinx, obj: Any, bound_method: bool) -> None: + """Update defvalue info of *obj* using type_comments.""" + try: + function = get_function_def(obj) + if function.args.defaults or function.args.kw_defaults: + sig = inspect.signature(obj) + defaults = list(function.args.defaults) + kw_defaults = list(function.args.kw_defaults) + parameters = list(sig.parameters.values()) + for i, param in enumerate(parameters): + if param.default is not param.empty: + if param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + value = DefaultValue(ast_unparse(defaults.pop(0))) + parameters[i] = param.replace(default=value) + else: + value = DefaultValue(ast_unparse(kw_defaults.pop(0))) + parameters[i] = param.replace(default=value) + sig = sig.replace(parameters=parameters) + obj.__signature__ = sig + except NotImplementedError as exc: # failed to ast.unparse() + logger.warning(__("Failed to parse type_comment for %r: %s"), obj, exc) + + +def setup(app: Sphinx) -> Dict[str, Any]: + app.setup_extension('sphinx.ext.autodoc') + app.connect('autodoc-before-process-signature', update_defvalue) + + return { + 'version': '1.0', + 'parallel_read_safe': True + } diff --git a/tests/roots/test-ext-autodoc/target/preserve_defaults.py b/tests/roots/test-ext-autodoc/target/preserve_defaults.py new file mode 100644 index 00000000000..5b60ec38e92 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/preserve_defaults.py @@ -0,0 +1,20 @@ +from datetime import datetime +from typing import Any + + +CONSTANT = 'foo' +SENTINEL = object() + + +def foo(name: str = CONSTANT, + sentinal: Any = SENTINEL, + now: datetime = datetime.now()) -> None: + """docstring""" + + +class Class: + """docstring""" + + def meth(self, name: str = CONSTANT, sentinal: Any = SENTINEL, + now: datetime = datetime.now()) -> None: + """docstring""" diff --git a/tests/test_ext_autodoc_preserve_defaults.py b/tests/test_ext_autodoc_preserve_defaults.py new file mode 100644 index 00000000000..7a5a8091465 --- /dev/null +++ b/tests/test_ext_autodoc_preserve_defaults.py @@ -0,0 +1,45 @@ +""" + test_ext_autodoc_preserve_defaults + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Test the autodoc extension. + + :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +import pytest + +from .test_ext_autodoc import do_autodoc + + +@pytest.mark.sphinx('html', testroot='ext-autodoc', + confoverrides={'extensions': ['sphinx.ext.autodoc.preserve_defaults']}) +def test_preserve_defaults(app): + options = {"members": None} + actual = do_autodoc(app, 'module', 'target.preserve_defaults', options) + assert list(actual) == [ + '', + '.. py:module:: target.preserve_defaults', + '', + '', + '.. py:class:: Class', + ' :module: target.preserve_defaults', + '', + ' docstring', + '', + '', + ' .. py:method:: Class.meth(name: str = CONSTANT, sentinal: Any = SENTINEL, ' + 'now: datetime.datetime = datetime.now()) -> None', + ' :module: target.preserve_defaults', + '', + ' docstring', + '', + '', + '.. py:function:: foo(name: str = CONSTANT, sentinal: Any = SENTINEL, now: ' + 'datetime.datetime = datetime.now()) -> None', + ' :module: target.preserve_defaults', + '', + ' docstring', + '', + ]