#!/usr/bin/env python3
#
# __init__.py
"""
A Sphinx directive to specify that a module has extra requirements, and show how to install them.
"""
#
# 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
import typing
from typing import Any, Dict, 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
__author__: str = "Dominic Davis-Foster"
__copyright__: str = "2020 Dominic Davis-Foster"
__license__: str = "MIT"
__version__: str = "0.0.11"
__email__: str = "dominic@davis-foster.co.uk"
__all__ = ["process_docstring", "process_default_format", "setup"]
default_regex: typing.Pattern = re.compile(r"^:(default|Default) ")
no_default_regex: typing.Pattern = re.compile(r"^:(No|no)[-_](default|Default) ")
[docs]def process_docstring(app: Sphinx, what, name, obj, options, lines: typing.List[str]) -> None:
"""
Add default values to the docstring.
:param app:
:param what:
:type what:
:param name:
:type name:
:param obj:
:type obj:
:param options:
:type options:
:param lines: List of strings representing the current contents of the docstring.
"""
# Size varies depending on docutils config
a_tab = " " * app.config.docutils_tab_width # type: ignore
if isinstance(obj, property):
return None
if callable(obj):
if inspect.isclass(obj):
obj = getattr(obj, "__init__")
obj = inspect.unwrap(obj)
try:
signature = Signature(obj)
except ValueError:
return None
default_description_format: str = app.config.default_description_format # type: ignore
for argname, param in signature.parameters.items():
if argname.endswith('_'):
argname = f"{argname[:-1]}\\_"
default_value = param.default
formatted_annotation = None
# Get the default value from the signature
if default_value is not inspect.Signature.empty and default_value is not Ellipsis:
if isinstance(default_value, bool):
formatted_annotation = f":py:obj:`{default_value}`"
elif default_value is None:
formatted_annotation = ":py:obj:`None`"
elif isinstance(default_value, str):
formatted_annotation = f"``{default_value.replace(' ', '␣')!r}``"
else:
formatted_annotation = f"``{default_value!r}``"
# Check if the user has overridden the default value in the docstring
default_searchfor = [f":{field} {argname}:" for field in ("default", "Default")]
for i, line in enumerate(lines):
for search_string in default_searchfor:
if line.startswith(search_string):
formatted_annotation = line.split(search_string)[-1].lstrip(" ")
lines.remove(line)
break
# Check the user hasn't turned the default argument off
no_default_searchfor = re.compile(fr"^:(No|no)[-_](default|Default) {argname}:")
for i, line in enumerate(lines):
if no_default_searchfor.match(line):
formatted_annotation = None
break
# Add the default value
searchfor = [f":{field} {argname}:" for field in ("param", "parameter", "arg", "argument")]
insert_index = None
for i, line in enumerate(lines):
if any(line.startswith(search_string) for search_string in searchfor):
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(" " * 4):
# Ensure the previous line has a fullstop at the end.
if lines[insert_index + idx][-1] not in ".,;:":
lines[insert_index + idx] += '.'
lines.insert(
insert_index + 1 + idx,
f"{a_tab}{default_description_format % formatted_annotation}."
)
break
# Remove all remaining :default *: lines
for i, line in enumerate(lines):
if default_regex.match(line):
lines.remove(line)
# Remove all remaining :no-default *: lines
for i, line in enumerate(lines):
if no_default_regex.match(line):
lines.remove(line)
return None
[docs]def setup(app: Sphinx) -> Dict[str, Any]:
"""
Setup Sphinx Extension.
:param app:
:return:
"""
# 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:
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,
}