Passed
Pull Request — main (#155)
by Oliver
02:24
created

attributetable.process_attributetable()   B

Complexity

Conditions 6

Size

Total Lines 20
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 16
dl 0
loc 20
rs 8.6666
c 0
b 0
f 0
cc 6
nop 3
1
from sphinx.util.docutils import SphinxDirective
2
from sphinx.locale import _
3
from docutils import nodes
4
from sphinx import addnodes
5
6
from collections import OrderedDict, namedtuple
7
import importlib
8
import inspect
9
import os
10
import re
11
12
13
class attributetable(nodes.General, nodes.Element):
14
    pass
15
16
17
class attributetablecolumn(nodes.General, nodes.Element):
18
    pass
19
20
21
class attributetabletitle(nodes.TextElement):
22
    pass
23
24
25
class attributetableplaceholder(nodes.General, nodes.Element):
26
    pass
27
28
29
class attributetablebadge(nodes.TextElement):
30
    pass
31
32
33
class attributetable_item(nodes.Part, nodes.Element):
34
    pass
35
36
37
def visit_attributetable_node(self, node):
38
    class_ = node["python-class"]
39
    self.body.append(
40
        f'<div class="py-attribute-table" data-move-to-id="{class_}">'
41
    )
42
43
44
def visit_attributetablecolumn_node(self, node):
45
    self.body.append(self.starttag(
46
        node, 'div', CLASS='py-attribute-table-column')
47
    )
48
49
50
def visit_attributetabletitle_node(self, node):
51
    self.body.append(self.starttag(node, 'span'))
52
53
54
def visit_attributetablebadge_node(self, node):
55
    attributes = {
56
        'class': 'py-attribute-table-badge',
57
        'title': node['badge-type'],
58
    }
59
    self.body.append(self.starttag(node, 'span', **attributes))
60
61
62
def visit_attributetable_item_node(self, node):
63
    self.body.append(self.starttag(
64
        node, 'li', CLASS='py-attribute-table-entry')
65
    )
66
67
68
def depart_attributetable_node(self, node):
69
    self.body.append('</div>')
70
71
72
def depart_attributetablecolumn_node(self, node):
73
    self.body.append('</div>')
74
75
76
def depart_attributetabletitle_node(self, node):
77
    self.body.append('</span>')
78
79
80
def depart_attributetablebadge_node(self, node):
81
    self.body.append('</span>')
82
83
84
def depart_attributetable_item_node(self, node):
85
    self.body.append('</li>')
86
87
88
_name_parser_regex = re.compile(r'(?P<module>[\w.]+\.)?(?P<name>\w+)')
89
90
91
class PyAttributeTable(SphinxDirective):
92
    has_content = False
93
    required_arguments = 1
94
    optional_arguments = 0
95
    final_argument_whitespace = False
96
    option_spec = {}
97
98
    def parse_name(self, content):
99
        path, name = _name_parser_regex.match(content).groups()
100
        if path:
101
            modulename = path.rstrip('.')
102
        else:
103
            modulename = self.env.temp_data.get('autodoc:module')
104
            if not modulename:
105
                modulename = self.env.ref_context.get('py:module')
106
        if modulename is None:
107
            raise RuntimeError('modulename somehow None for %s in %s.' % (
108
                content, self.env.docname))
109
110
        return modulename, name
111
112
    def run(self):
113
        """If you're curious on the HTML this is meant to generate:
114
        <div class="py-attribute-table">
115
            <div class="py-attribute-table-column">
116
                <span>_('Attributes')</span>
117
                <ul>
118
                    <li>
119
                        <a href="...">
120
                    </li>
121
                </ul>
122
            </div>
123
            <div class="py-attribute-table-column">
124
                <span>_('Methods')</span>
125
                <ul>
126
                    <li>
127
                        <a href="..."></a>
128
                        <span class="py-attribute-badge" title="decorator">D</span>
129
                    </li>
130
                </ul>
131
            </div>
132
        </div>
133
        However, since this requires the tree to be complete
134
        and parsed, it'll need to be done at a different stage and then
135
        replaced.
136
        """
137
        content = self.arguments[0].strip()
138
        node = attributetableplaceholder('')
139
        modulename, name = self.parse_name(content)
140
        node['python-doc'] = self.env.docname
141
        node['python-module'] = modulename
142
        node['python-class'] = name
143
        node['python-full-name'] = f'{modulename}.{name}'
144
        return [node]
145
146
147
def build_lookup_table(env):
148
    # Given an environment, load up a lookup table of
149
    # full-class-name: objects
150
    result = {}
151
    domain = env.domains['py']
152
153
    ignored = {
154
        'data', 'exception', 'module', 'class',
155
    }
156
157
    for (fullname, _, objtype, docname, _, _) in domain.get_objects():
158
        if objtype in ignored:
159
            continue
160
161
        classname, _, child = fullname.rpartition('.')
162
        try:
163
            result[classname].append(child)
164
        except KeyError:
165
            result[classname] = [child]
166
167
    return result
168
169
170
TableElement = namedtuple('TableElement', 'fullname label badge')
171
172
173
def process_attributetable(app, doctree, fromdocname):
174
    env = app.builder.env
175
176
    lookup = build_lookup_table(env)
177
    for node in doctree.traverse(attributetableplaceholder):
178
        modulename, classname, fullname = node['python-module'], node['python-class'], node['python-full-name']
179
        groups = get_class_results(lookup, modulename, classname, fullname)
180
        table = attributetable('')
181
        for label, subitems in groups.items():
182
            if not subitems:
183
                continue
184
            table.append(class_results_to_node(
185
                label, sorted(subitems, key=lambda c: c.label)))
186
187
        table['python-class'] = fullname
188
189
        if not table:
190
            node.replace_self([])
191
        else:
192
            node.replace_self([table])
193
194
195
def get_class_results(lookup, modulename, name, fullname):
196
    module = importlib.import_module(modulename)
197
    cls = getattr(module, name)
198
199
    groups = OrderedDict([
200
        (_('Attributes'), []),
201
        (_('Methods'), []),
202
    ])
203
204
    try:
205
        members = lookup[fullname]
206
    except KeyError:
207
        return groups
208
209
    for attr in members:
210
        attrlookup = f'{fullname}.{attr}'
211
        key = _('Attributes')
212
        badge = None
213
        label = attr
214
        value = None
215
216
        for base in cls.__mro__:
217
            value = base.__dict__.get(attr)
218
            if value is not None:
219
                break
220
221
        if value is not None:
222
            doc = value.__doc__ or ''
223
            if inspect.iscoroutinefunction(value) or doc.startswith('|coro|'):
224
                key = _('Methods')
225
                badge = attributetablebadge('async', 'async')
226
                badge['badge-type'] = _('coroutine')
227
            elif isinstance(value, classmethod):
228
                key = _('Methods')
229
                label = f'{name}.{attr}'
230
                badge = attributetablebadge('cls', 'cls')
231
                badge['badge-type'] = _('classmethod')
232
            elif (
233
                inspect.isfunction(value)
234
                or isinstance(value, staticmethod)
235
            ):
236
                if (
237
                    doc.startswith(('A decorator', 'A shortcut decorator'))
238
                    or label in ("event", "loop")
239
                ):
240
                    # finicky but surprisingly consistent
241
                    badge = attributetablebadge('@', '@')
242
                    badge['badge-type'] = _('decorator')
243
                    key = _('Methods')
244
                else:
245
                    key = _('Methods')
246
                    badge = attributetablebadge('def', 'def')
247
                    badge['badge-type'] = _('method')
248
249
        groups[key].append(TableElement(
250
            fullname=attrlookup, label=label, badge=badge))
251
252
    return groups
253
254
255
def class_results_to_node(key, elements):
256
    title = attributetabletitle(key, key)
257
    ul = nodes.bullet_list('')
258
    for element in elements:
259
        ref = nodes.reference('', '', internal=True,
260
                              refuri='#' + element.fullname,
261
                              anchorname='',
262
                              *[nodes.Text(element.label)])
263
        para = addnodes.compact_paragraph('', '', ref)
264
        if element.badge is not None:
265
            ul.append(attributetable_item('', element.badge, para))
266
        else:
267
            ul.append(attributetable_item('', para))
268
269
    return attributetablecolumn('', title, ul)
270
271
272
def setup(app):
273
    app.add_directive('attributetable', PyAttributeTable)
274
    app.add_node(attributetable, html=(
275
        visit_attributetable_node, depart_attributetable_node))
276
    app.add_node(attributetablecolumn, html=(
277
        visit_attributetablecolumn_node, depart_attributetablecolumn_node))
278
    app.add_node(attributetabletitle, html=(
279
        visit_attributetabletitle_node, depart_attributetabletitle_node))
280
    app.add_node(attributetablebadge, html=(
281
        visit_attributetablebadge_node, depart_attributetablebadge_node))
282
    app.add_node(attributetable_item, html=(
283
        visit_attributetable_item_node, depart_attributetable_item_node))
284
    app.add_node(attributetableplaceholder)
285
    app.connect('doctree-resolved', process_attributetable)
286