File: dynamic_data.py

package info (click to toggle)
debusine 0.14.1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 15,056 kB
  • sloc: python: 193,072; sh: 848; javascript: 335; makefile: 116
file content (159 lines) | stat: -rw-r--r-- 4,939 bytes parent folder | download | duplicates (5)
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]