from __future__ import annotations import importlib import inspect import re from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Sequence, Tuple from docutils import nodes from sphinx import addnodes from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx.locale import _ from sphinx.util.docutils import SphinxDirective from sphinx.util.typing import OptionSpec if TYPE_CHECKING: from .builder import DPYHTML5Translator 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: DPYHTML5Translator, node: attributetable) -> None: class_ = node["python-class"] self.body.append(f'
') def visit_attributetablecolumn_node(self: DPYHTML5Translator, node: attributetablecolumn) -> None: self.body.append(self.starttag(node, "div", CLASS="py-attribute-table-column")) def visit_attributetabletitle_node(self: DPYHTML5Translator, node: attributetabletitle) -> None: self.body.append(self.starttag(node, "span")) def visit_attributetablebadge_node(self: DPYHTML5Translator, node: attributetablebadge) -> None: attributes = { "class": "py-attribute-table-badge", "title": node["badge-type"], } self.body.append(self.starttag(node, "span", **attributes)) def visit_attributetable_item_node(self: DPYHTML5Translator, node: attributetable_item) -> None: self.body.append(self.starttag(node, "li", CLASS="py-attribute-table-entry")) def depart_attributetable_node(self: DPYHTML5Translator, node: attributetable) -> None: self.body.append("
") def depart_attributetablecolumn_node(self: DPYHTML5Translator, node: attributetablecolumn) -> None: self.body.append("") def depart_attributetabletitle_node(self: DPYHTML5Translator, node: attributetabletitle) -> None: self.body.append("") def depart_attributetablebadge_node(self: DPYHTML5Translator, node: attributetablebadge) -> None: self.body.append("") def depart_attributetable_item_node(self: DPYHTML5Translator, node: attributetable_item) -> None: 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: OptionSpec = {} def parse_name(self, content: str) -> Tuple[str, str]: match = _name_parser_regex.match(content) if match is None: raise RuntimeError( f"content {content} somehow doesn't match regex in {self.env.docname}." ) path, name = match.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(f"modulename somehow None for {content} in {self.env.docname}.") return modulename, name def run(self) -> List[attributetableplaceholder]: """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: Optional[BuildEnvironment]) -> Dict[str, List[str]]: # Given an environment, load up a lookup table of # full-class-name: objects result = {} domain = env.domains["py"] # type: ignore 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 class TableElement(NamedTuple): fullname: str label: str badge: Optional[attributetablebadge] def process_attributetable(app: Sphinx, doctree: nodes.Node, fromdocname: str) -> None: env = app.builder.env # type: ignore 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, List[str]], modulename: str, name: str, fullname: str ) -> Dict[str, List[TableElement]]: module = importlib.import_module(modulename) cls = getattr(module, name) groups: Dict[str, List[TableElement]] = { _("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 key = _("Methods") badge = attributetablebadge("@", "@") badge["badge-type"] = _("decorator") elif inspect.isasyncgenfunction(value): key = _("Methods") badge = attributetablebadge("async for", "async for") badge["badge-type"] = _("async iterable") 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: str, elements: Sequence[TableElement]) -> attributetablecolumn: title = attributetabletitle(key, key) ul = nodes.bullet_list("") for element in elements: ref = nodes.reference( "", "", internal=True, refuri=f"#{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: Sphinx) -> None: 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)