#!/usr/bin/env python3
#
# __init__.py
"""
Sphinx extension to show default values in documentation.
"""
#
# Copyright © 2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
#
# Based on https://github.com/agronholm/sphinx-autodoc-typehints
# Copyright (c) Alex Grönholm
# MIT Licensed
#
# stdlib
import inspect
import re
import string
from types import BuiltinFunctionType, FunctionType, ModuleType
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Pattern, Tuple, Type, Union
# 3rd party
from docutils.nodes import document
from docutils.statemachine import StringList
from sphinx.application import Sphinx
from sphinx.parsers import RSTParser
from sphinx.util.inspect import signature as Signature
try:
# 3rd party
import attr
except ImportError: # pragma: no cover
# attrs is used in a way that it is only required in situations
# where it is available to import, so its fine to do this.
pass
__author__: str = "Dominic Davis-Foster"
__copyright__: str = "2020 Dominic Davis-Foster"
__license__: str = "MIT"
__version__: str = "0.6.0"
__email__: str = "dominic@davis-foster.co.uk"
__all__ = [
"process_docstring",
"process_default_format",
"setup",
"get_class_defaults",
"get_function_defaults",
"default_regex",
"no_default_regex",
"get_arguments",
"format_default_value",
]
default_regex: Pattern = re.compile("^:(?i:default) ")
"""
Regular expression to match default values declared in docstrings.
.. versionchanged:: 0.5.0 Change to be case insensitive.
"""
no_default_regex: Pattern = re.compile("^:(?i:no[-_]default) ")
"""
Regular expression to match fields in docstrings to suppress default values.
.. versionchanged:: 0.5.0 Change to be case insensitive.
"""
# ref: sphinx.domains.python.PyObject.doc_field_types
_fields = '|'.join([
"param",
"parameter",
"arg",
"argument",
"keyword",
"kwarg",
"kwparam",
])
def escape_trailing__(string: str) -> str:
"""
Returns the given string with trailing underscores escaped to prevent Sphinx treating them as references.
:param string:
"""
if string.endswith('_'):
return f"{string[:-1]}\\_"
return string
[docs]def process_docstring(
app: Sphinx,
what: str,
name: str,
obj: Any,
options: Dict[str, Any],
lines: List[str],
) -> None:
"""
Add default values to the docstring.
:param app: The Sphinx app.
:param what:
:param name: The name of the object being documented.
:param obj: The object being documented.
:param options: Mapping of autodoc options to values.
:param lines: List of strings representing the current contents of the docstring.
"""
if isinstance(obj, property):
return None
# Size varies depending on docutils config
a_tab = ' ' * app.config.docutils_tab_width
if callable(obj):
if not lines or lines[-1]:
lines.append('')
default_getter: Union[Callable[[Type], _defaults], Callable[[Callable], _defaults]]
if inspect.isclass(obj):
default_getter = get_class_defaults
else:
default_getter = get_function_defaults
default_description_format: str = app.config.default_description_format
for argname, default_value in default_getter(obj):
argname = escape_trailing__(argname)
# Get the default value from the signature
formatted_annotation = format_default_value(default_value)
# Check if the user has overridden the default value in the docstring
default_searchfor = re.compile(fr"^:(?i:default) {re.escape(argname)}:")
for i, line in enumerate(lines):
if default_searchfor.match(line):
formatted_annotation = ':'.join(line.split(':')[2:]).lstrip()
lines.remove(line)
break
# Check the user hasn't turned the default argument off
no_default_searchfor = re.compile(f"^:(?i:no[-_]default) {re.escape(argname)}:")
for i, line in enumerate(lines):
if no_default_searchfor.match(line):
formatted_annotation = None
break
# Add the default value
insert_index = None
param_searchfor = re.compile(f"^:({_fields}) {re.escape(argname)}:")
for i, line in enumerate(lines):
if param_searchfor.match(line):
insert_index = i
break
if formatted_annotation is not None:
if insert_index is not None:
# Look ahead to find the index of the next unindented line, and insert before it.
for idx, line in enumerate(lines[insert_index + 1:]):
if not line.startswith(a_tab):
# Ensure the previous line has a fullstop at the end.
line_content = ':'.join(lines[insert_index + idx].split(':')[2:]).strip()
if line_content and line_content[-1] not in ".,;:":
lines[insert_index + idx] += '.'
lines.insert(
insert_index + 1 + idx,
f"{a_tab}{default_description_format % formatted_annotation}".rstrip('.') + '.'
)
break
indices_to_remove = set()
# Remove all remaining :default *: lines
for i, line in enumerate(lines):
if default_regex.match(line):
indices_to_remove.add(i)
# Remove all remaining :no-default *: lines
for i, line in enumerate(lines):
if no_default_regex.match(line):
indices_to_remove.add(i)
for i in sorted(indices_to_remove, reverse=True):
del lines[i]
return None
_defaults = Iterator[Tuple[str, Any]]
[docs]def get_class_defaults(obj: Type) -> _defaults:
"""
Obtains the default values for the arguments of a class.
:param obj: The class.
:return: An iterator of 2-element tuples comprising the argument name and its default value.
"""
# TODO: handle __new__
for argname, param in get_arguments(getattr(obj, "__init__")).items():
default_value = param.default
if hasattr(obj, "__attrs_attrs__"):
# Special casing for attrs classes
if default_value is attr.NOTHING:
for value in obj.__attrs_attrs__:
if value.name == argname and isinstance(value.default, attr.Factory): # type: ignore
default_value = value.default.factory()
yield argname, default_value
[docs]def get_function_defaults(obj: Callable) -> _defaults:
"""
Obtains the default values for the arguments of a function.
:param obj: The function.
:return: An iterator of 2-element tuples comprising the argument name and its default value.
"""
for argname, param in get_arguments(obj).items():
yield argname, param.default
[docs]def get_arguments(obj: Callable) -> Mapping[str, inspect.Parameter]:
"""
Returns a dictionary mapping argument names to parameters/arguments for a function.
:param obj: A function (can be the ``__init__`` method of a class).
"""
try:
signature = Signature(inspect.unwrap(obj))
except ValueError: # pragma: no cover
return {}
return signature.parameters
[docs]def setup(app: Sphinx) -> Dict[str, Any]:
"""
Setup :mod:`sphinxcontrib.default_values`.
:param app:
"""
# Custom formatting for the default value indication
app.add_config_value("default_description_format", "Default %s", "env", [str])
app.connect("builder-inited", process_default_format)
app.connect("autodoc-process-docstring", process_docstring)
# Hack to get the docutils tab size, as there doesn't appear to be any other way
class CustomRSTParser(RSTParser):
def parse(self, inputstring: Union[str, StringList], document: document) -> None: # pragma: no cover
app.config.docutils_tab_width = document.settings.tab_width # type: ignore
super().parse(inputstring, document)
app.add_source_parser(CustomRSTParser, override=True)
return {
"version": __version__,
"parallel_read_safe": True,
"parallel_write_safe": True,
}