from __future__ import annotations import importlib import inspect import re from collections import OrderedDict, namedtuple from typing import Any, Dict from docutils import nodes from sphinx import addnodes, application from sphinx.locale import _ from sphinx.util.docutils import SphinxDirective class attributetable(nodes.General, nodes.Element): pass class attributetablecolumn(nodes.General, nodes.Element): pass class attributetabletitle(nodes.TextElement): pass class attributetableplaceholder(nodes.General, nodes.Element): pass class attributetablebadge(nodes.TextElement): pass class attributetable_item(nodes.Part, nodes.Element): pass def visit_attributetable_node(self, node): class_ = node["python-class"] self.body.append(f'
') def visit_attributetablecolumn_node(self, node): self.body.append(self.starttag(node, 'div', CLASS='py-attribute-table-column')) def visit_attributetabletitle_node(self, node): self.body.append(self.starttag(node, 'span')) def visit_attributetablebadge_node(self, node): attributes = { 'class': 'py-attribute-table-badge', 'title': node['badge-type'], } self.body.append(self.starttag(node, 'span', **attributes)) def visit_attributetable_item_node(self, node): self.body.append(self.starttag(node, 'li', CLASS='py-attribute-table-entry')) def depart_attributetable_node(self, node): self.body.append('
') def depart_attributetablecolumn_node(self, node): self.body.append('') def depart_attributetabletitle_node(self, node): self.body.append('') def depart_attributetablebadge_node(self, node): self.body.append('') def depart_attributetable_item_node(self, node): self.body.append('') _name_parser_regex = re.compile(r'(?P[\w.]+\.)?(?P\w+)') class PyAttributeTable(SphinxDirective): has_content = False required_arguments = 1 optional_arguments = 0 final_argument_whitespace = False option_spec = {} def parse_name(self, content: str): path, name = _name_parser_regex.match(content).groups() if path: modulename = path.rstrip('.') else: modulename = self.env.temp_data.get('autodoc:module') if not modulename: modulename = self.env.ref_context.get('py:module') if modulename is None: raise RuntimeError('modulename somehow None for %s in %s.' % (content, self.env.docname)) return modulename, name def run(self): """If you're curious on the HTML this is meant to generate: However, since this requires the tree to be complete and parsed, it'll need to be done at a different stage and then replaced. """ content = self.arguments[0].strip() node = attributetableplaceholder('') modulename, name = self.parse_name(content) node['python-doc'] = self.env.docname node['python-module'] = modulename node['python-class'] = name node['python-full-name'] = f'{modulename}.{name}' return [node] def build_lookup_table(env): # Given an environment, load up a lookup table of # full-class-name: objects result = {} domain = env.domains['py'] ignored = { 'data', 'exception', 'module', 'class', } for (fullname, _, objtype, docname, _, _) in domain.get_objects(): if objtype in ignored: continue classname, _, child = fullname.rpartition('.') try: result[classname].append(child) except KeyError: result[classname] = [child] return result TableElement = namedtuple('TableElement', 'fullname label badge') def process_attributetable(app: application.Sphinx, doctree, fromdocname: str): env = app.builder.env lookup = build_lookup_table(env) for node in doctree.traverse(attributetableplaceholder): modulename, classname, fullname = node['python-module'], node['python-class'], node['python-full-name'] groups = get_class_results(lookup, modulename, classname, fullname) table = attributetable('') for label, subitems in groups.items(): if not subitems: continue table.append(class_results_to_node(label, sorted(subitems, key=lambda c: c.label))) table['python-class'] = fullname if not table: node.replace_self([]) else: node.replace_self([table]) def get_class_results(lookup: Dict[str, Any], modulename: str, name: str, fullname: str): module = importlib.import_module(modulename) cls = getattr(module, name) groups = OrderedDict([ (_('Attributes'), []), (_('Methods'), []), ]) try: members = lookup[fullname] except KeyError: return groups for attr in members: attrlookup = f'{fullname}.{attr}' key = _('Attributes') badge = None label = attr value = None for base in cls.__mro__: value = base.__dict__.get(attr) if value is not None: break if value is not None: doc = value.__doc__ or '' if inspect.iscoroutinefunction(value) or doc.startswith('|coro|'): key = _('Methods') badge = attributetablebadge('async', 'async') badge['badge-type'] = _('coroutine') elif isinstance(value, classmethod): key = _('Methods') label = f'{name}.{attr}' badge = attributetablebadge('cls', 'cls') badge['badge-type'] = _('classmethod') elif inspect.isfunction(value): if doc.startswith(('A decorator', 'A shortcut decorator')): # finicky but surprisingly consistent badge = attributetablebadge('@', '@') badge['badge-type'] = _('decorator') key = _('Methods') else: key = _('Methods') badge = attributetablebadge('def', 'def') badge['badge-type'] = _('method') groups[key].append(TableElement(fullname=attrlookup, label=label, badge=badge)) return groups def class_results_to_node(key, elements): title = attributetabletitle(key, key) ul = nodes.bullet_list('') for element in elements: ref = nodes.reference('', '', internal=True, refuri='#' + element.fullname, anchorname='', *[nodes.Text(element.label)]) para = addnodes.compact_paragraph('', '', ref) if element.badge is not None: ul.append(attributetable_item('', element.badge, para)) else: ul.append(attributetable_item('', para)) return attributetablecolumn('', title, ul) def setup(app): app.add_directive('attributetable', PyAttributeTable) app.add_node(attributetable, html=(visit_attributetable_node, depart_attributetable_node)) app.add_node(attributetablecolumn, html=(visit_attributetablecolumn_node, depart_attributetablecolumn_node)) app.add_node(attributetabletitle, html=(visit_attributetabletitle_node, depart_attributetabletitle_node)) app.add_node(attributetablebadge, html=(visit_attributetablebadge_node, depart_attributetablebadge_node)) app.add_node(attributetable_item, html=(visit_attributetable_item_node, depart_attributetable_item_node)) app.add_node(attributetableplaceholder) app.connect('doctree-resolved', process_attributetable)