1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
|
# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.
"""Extract data from docstrings and format it as RST."""
import inspect
import re
from docutils import nodes
from docutils.nodes import Node
from docutils.parsers.rst import Directive
from sphinx.ext.autodoc.importer import import_object
from sphinx.util.typing import OptionSpec
class DynamicDataDirective(Directive):
"""
Sphinx directive to document dynamic data.
To use, in an .rst file:
.. code-block:: rst
.. dynamic_data::
:method: debusine.tasks.sbuild::Sbuild.build_dynamic_data
In the Sbuild.build_dynamic_data method include in its docstring:
.. code-block:: rst
Computes the dynamic data for the task resolving artifacts.
:subject: source package that is building
:runtime_context: the context
"""
required_arguments = 0
optional_arguments = 0
final_argument_whitespace = False
option_spec: OptionSpec = {
"method": str,
}
def run(self) -> list[Node]:
"""Run the dynamic data directive."""
method_path = self.options.get("method")
if not method_path or "::" not in method_path:
return [
self.state_machine.reporter.error(
'"dynamic_data" directive requires "method" option '
'in the format of "method: module_path::Class.method"',
line=self.lineno,
)
]
module_name, obj_path = method_path.split("::", 1)
try:
_, _, _, method = import_object(
module_name, obj_path.split("."), objtype="method"
)
except ModuleNotFoundError:
return [
self.state_machine.reporter.error(
f'Cannot import "{method_path}". '
f'Expected "module::Class.method"',
line=self.lineno,
)
]
docstring = inspect.getdoc(method)
if docstring is None:
return [
self.state_machine.reporter.error(
f"No docstring found in '{method_path}'",
line=self.lineno,
)
]
parsed = self._extract_dynamic_data_fields(docstring)
if len(parsed) == 0:
return [
self.state_machine.reporter.error(
'No "Dynamic Data:" block or field keys found in '
f'"{method_path}"',
line=self.lineno,
)
]
return self._generate_dynamic_data_rst(parsed)
@staticmethod
def _extract_dynamic_data_fields(docstring: str) -> dict[str, str]:
"""
Extract dynamic data fields from a docstring using field lists.
:return: dictionary with dynamic data field names and descriptions.
"""
dynamic_data = {}
lines = docstring.splitlines()
field_indentation_level = 0
current_key: str | None = None
for line in lines:
stripped = line.strip()
current_indentation_level = len(line) - len(line.lstrip(" "))
if (
current_key is not None
and current_indentation_level > field_indentation_level
):
dynamic_data[current_key] += f" {stripped}"
continue
match = re.match(r":(\w+):\s*(.*)", stripped)
if match:
key, value = match.groups()
if key in ["returns", "return"]:
# Skip "returns" / "return" which is likely to be what the
# method return, not a configuration key
continue
current_key = key
dynamic_data[current_key] = value
field_indentation_level = current_indentation_level
return dynamic_data
def _generate_dynamic_data_rst(
self, dynamic_data: dict[str, str]
) -> list[nodes.bullet_list]:
"""Generate a Sphinx field list from extracted dynamic data."""
bullet_list = nodes.bullet_list()
for key, value in dynamic_data.items():
contents = nodes.paragraph()
contents += nodes.strong(text=f"{key}: ")
inline_nodes, _ = self.state.inline_text(value, self.lineno)
contents += inline_nodes
list_item = nodes.list_item()
list_item += contents
bullet_list.append(list_item)
return [bullet_list]
|