Source code for sphinxcontrib_enum.directive

"""
The ``enum-table`` directive and the download node it emits.
"""

import hashlib
import inspect
import re
import typing as t
from enum import Enum
from html import escape
from importlib import import_module
from pathlib import Path

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.errors import ConfigError
from sphinx.util import logging
from sphinx.util.docutils import SphinxDirective, SphinxTranslator
from sphinx.util.osutil import ensuredir, relative_uri
from sphinx.util.parsing import nested_parse_to_nodes

from .introspect import (
    NAME,
    column_docstrings,
    default_columns,
    format_value,
    import_enum,
    member_docstrings,
    resolve,
    to_csv,
    to_json,
    to_json_value,
)

__all__ = [
    "DEFAULT_DOC_COLUMN",
    "DOWNLOAD_DIR",
    "DOWNLOAD_FORMATS",
    "EnumTableDirective",
    "Formatter",
    "download_formats",
    "enum_table_downloads",
    "enum_table_legend",
]

logger = logging.getLogger(__name__)

Cell: t.TypeAlias = str | nodes.Node | list[nodes.Node]
"""A rendered table cell: text, a node or a list of body nodes (parsed docstrings)."""

Formatter = t.Callable[[Enum, str, t.Any], "str | nodes.Node | None"]
"""
The signature of a cell formatter. Formatters are passed the enum member, the
column name and the raw column value and return either the display text, a
docutils node or ``None`` to fall back to the default formatting.
"""

DEFAULT_DOC_COLUMN = "doc"
"""The default name of the column that holds member docstrings."""

DOWNLOAD_FORMATS = ("csv", "json")
"""The supported download formats."""

DOWNLOAD_DIR = "_downloads/sphinxcontrib_enum"
"""The directory relative to the html output directory that download files go in."""

_MAX_AUTO_WIDTH = 40

_DOWNLOAD_ICON = (
    '<svg aria-hidden="true" viewBox="0 0 16 16" width="14" height="14">'
    '<path fill="currentColor" d="M8 1a.75.75 0 0 1 .75.75v6.69l2.22-2.22a.75.75 0 '
    "1 1 1.06 1.06l-3.5 3.5a.75.75 0 0 1-1.06 0l-3.5-3.5a.75.75 0 1 1 1.06-1.06L7.25 "
    "8.44V1.75A.75.75 0 0 1 8 1ZM2.75 12.5a.75.75 0 0 0 0 1.5h10.5a.75.75 0 0 0 0-1.5"
    'H2.75Z"/></svg>'
)


class enum_table_legend(nodes.definition_list):
    """
    A definition list describing the table's columns. Builders render it as a
    definition list, the html writer also links it to its table with
    ``aria-describedby`` so assistive technology announces it with the table.
    """


class enum_table_downloads(nodes.General, nodes.Element):
    """
    A node holding the content of the table's download files. The html writer
    writes the files to the output directory and renders links to them, all other
    builders drop the node.

    Attributes:

    * ``basename``: the file name to use for the downloads, without extension
    * ``files``: a list of ``(format, content)`` tuples
    """


def _split(argument: str | None) -> list[str]:
    return [item for item in re.split(r"[\s,]+", argument or "") if item]


def columns_option(argument: str | None) -> list[str]:
    columns = _split(argument)
    if not columns:
        raise ValueError("at least one column is required.")
    return columns


def headers_option(argument: str | None) -> dict[str, str]:
    """Parse a comma separated list of ``column=Header`` pairs."""
    headers: dict[str, str] = {}
    for pair in (argument or "").split(","):
        if not pair.strip():
            continue
        column, sep, header = pair.partition("=")
        if not sep or not column.strip():
            raise ValueError(f"expected column=Header, got {pair.strip()!r}")
        headers[column.strip()] = header.strip()
    return headers


def download_formats(value: t.Any) -> list[str]:
    """
    Normalize a download setting into a list of formats.

    * falsey values (``False``, ``None``, empty) disable downloads
    * ``True`` enables every supported format
    * strings are comma or whitespace separated formats (or ``none``)
    * any other iterable is a collection of formats

    :raises ValueError: If a format is not supported.
    """
    if not value:
        return []
    if value is True:
        return list(DOWNLOAD_FORMATS)
    items = _split(value) if isinstance(value, str) else list(value)
    formats = [str(fmt).strip().lower() for fmt in items]
    if formats == ["none"]:
        return []
    for fmt in formats:
        if fmt not in DOWNLOAD_FORMATS:
            raise ValueError(
                f"unsupported download format {fmt!r}, "
                f"expected one of: {', '.join(DOWNLOAD_FORMATS)} or none"
            )
    return list(dict.fromkeys(formats))


def download_option(argument: str | None) -> list[str]:
    """Given without a value (like the other boolean options) offer every format."""
    if not (argument or "").strip():
        return list(DOWNLOAD_FORMATS)
    return download_formats(argument)


def bool_option(argument: str | None) -> bool:
    """A boolean option, given without a value it is ``True``."""
    value = (argument or "true").strip().lower()
    if value in ("true", "yes", "on", "1"):
        return True
    if value in ("false", "no", "off", "0"):
        return False
    raise ValueError(f"expected true or false, got {argument!r}")


def widths_option(argument: str | None) -> str | list[int]:
    if (argument or "").strip().lower() in ("auto", "grid"):
        return (argument or "").strip().lower()
    return directives.positive_int_list(argument or "")


def _load_formatter(formatter: t.Any) -> Formatter | None:
    if formatter is None or callable(formatter):
        return t.cast("Formatter | None", formatter)
    module, _, attr = str(formatter).replace(":", ".").rpartition(".")
    return t.cast(Formatter, getattr(import_module(module), attr))


[docs] class EnumTableDirective(SphinxDirective): """ Render an enumeration as a table with a row for each member and a column for the name, value and each property or dataclass field. .. code-block:: rst .. enum-table:: import.path.to.Enum :columns: name, mass, radius, moons :exclude: moons :members: EARTH, MARS :exclude-members: VENUS :headers: mass=Mass (kg), radius=Radius (m) :caption: The planets. :name: planet-table :class: my-class :widths: auto :download: csv, json :formatter: import.path.to.formatter :docs: true :doc-column: doc :legend: true """ required_arguments = 1 optional_arguments = 0 has_content = False option_spec: t.ClassVar[dict[str, t.Callable[[str], t.Any]]] = { "columns": columns_option, "exclude": _split, "members": _split, "exclude-members": _split, "headers": headers_option, "caption": directives.unchanged_required, "name": directives.unchanged, "class": directives.class_option, "widths": widths_option, "download": download_option, "formatter": directives.unchanged_required, "docs": bool_option, "legend": bool_option, "doc-column": directives.unchanged_required, } def run(self) -> list[nodes.Node]: try: enum_cls = import_enum( self.arguments[0], self.env.ref_context.get("py:module") ) except (ImportError, TypeError) as err: return self._warn(str(err)) try: self.env.note_dependency(inspect.getfile(enum_cls)) except (TypeError, OSError): # pragma: no cover - builtin/dynamic enums pass try: formatter = _load_formatter( self.options.get("formatter", self.config.enum_table_formatter) ) except (ImportError, AttributeError, ValueError) as err: return self._warn(f"Unable to import formatter: {err}") members = self._members(enum_cls) doc_column = self.options.get("doc-column", DEFAULT_DOC_COLUMN).strip() docs: dict[str, str] = {} if self.options.get("docs", True): docs = { name: doc for name, doc in member_docstrings(enum_cls).items() if name in {member.name for member in members} } # the doc column only exists if a rendered member has a docstring, otherwise # its name is an ordinary column doc_col = doc_column if docs else None columns = self._columns(enum_cls, members, doc_col) if not columns: return self._warn(f"No columns to render for {enum_cls.__qualname__}.") headers = self._headers(columns) display: list[list[Cell]] = [] raw: list[list[t.Any]] = [] for member in members: display.append([]) raw.append([]) for column in columns: if column == doc_col: value = docs.get(member.name, "") else: value = resolve(member, column) cell = formatter(member, column, value) if formatter else None if cell is None and column == doc_col: cell = self._parse_doc(enum_cls, member, value) # downloads get the rendered text rather than the markup value = _text(cell) elif cell is None: cell = format_value(value) display[-1].append(cell) raw[-1].append(value) table = self._table(headers, columns, display) messages: list[nodes.Node] = [] if "caption" in self.options: inline, parse_messages = self.parse_inline( self.options["caption"], lineno=self.lineno ) messages.extend(parse_messages) table.insert(0, nodes.title(self.options["caption"], "", *inline)) self.set_source_info(table) self.add_name(table) container = nodes.container(classes=["enum-table-container"]) container += table if self.options.get("legend", False): legend = self._legend(enum_cls, columns, headers) if legend is not None: # the html writer links the table to its legend by id if not table["ids"]: self.state.document.set_id(table) container += legend formats = self.options.get( "download", download_formats(self.config.enum_table_download) ) if formats: text = [[_text(cell) for cell in row] for row in display] contents = { "csv": lambda: to_csv(headers, text), "json": lambda: to_json( [member.name for member in members], columns, [ [_json(value, txt) for value, txt in zip(raw_row, text_row)] for raw_row, text_row in zip(raw, text) ], ), } container += enum_table_downloads( basename=enum_cls.__qualname__, files=[(fmt, contents[fmt]()) for fmt in formats], ) return [container, *messages] def _warn(self, message: str) -> list[nodes.Node]: logger.warning( message, location=self.get_location(), type="enum_table", subtype="error" ) return [] def _members(self, enum_cls: type[Enum]) -> list[Enum]: members = list(enum_cls) if "members" in self.options: by_name = {member.name: member for member in members} selected = [] for name in self.options["members"]: if name in by_name: selected.append(by_name[name]) else: self._warn(f"{enum_cls.__qualname__} has no member {name!r}.") members = selected exclude = set(self.options.get("exclude-members", [])) return [member for member in members if member.name not in exclude] def _legend( self, enum_cls: type[Enum], columns: list[str], headers: list[str] ) -> enum_table_legend | None: """A definition list of the documented columns, in column order.""" docs = column_docstrings(enum_cls, columns) if not docs: return None legend = enum_table_legend(classes=["enum-table-legend"]) for column, header in zip(columns, headers): if column not in docs: continue item = nodes.definition_list_item() item += nodes.term(header, header) definition = nodes.definition() definition += nested_parse_to_nodes( self.state, docs[column], source=f"docstring of {enum_cls.__module__}.{enum_cls.__qualname__}" f".{column}", allow_section_headings=False, ) item += definition legend += item self.state.document.set_id(legend) return legend def _parse_doc(self, enum_cls: type[Enum], member: Enum, doc: str) -> Cell: """Parse a member docstring as reStructuredText, like autodoc does.""" if not doc: return "" return nested_parse_to_nodes( self.state, doc, source=f"docstring of {enum_cls.__module__}.{enum_cls.__qualname__}" f".{member.name}", allow_section_headings=False, ) def _columns( self, enum_cls: type[Enum], members: list[Enum], doc_col: str | None ) -> list[str]: """ The columns to render. When members have docstrings (``doc_col`` is set) the doc column replaces a default column of the same name in place, or is appended. Explicit ``:columns:`` include it only where it is listed. """ columns = self.options.get("columns", None) if not columns: columns = default_columns(enum_cls) if doc_col and doc_col not in columns: columns.append(doc_col) exclude = set(self.options.get("exclude", [])) resolved = [] for column in columns: if column in exclude: continue if column == doc_col: resolved.append(column) continue try: for member in members: resolve(member, column) except AttributeError: self._warn( f"Unable to resolve column {column!r} for " f"{enum_cls.__qualname__}.{member.name}." ) continue resolved.append(column) return resolved def _headers(self, columns: list[str]) -> list[str]: headers = self.options.get("headers", {}) for column in headers: if column not in columns: self._warn(f"Header given for unknown column {column!r}.") return [headers.get(column, column) for column in columns] def _table( self, headers: list[str], columns: list[str], rows: list[list[Cell]], ) -> nodes.table: widths = self.options.get("widths", "auto") table = nodes.table(classes=["enum-table", *self.options.get("class", [])]) if widths == "auto": table["classes"].append("colwidths-auto") elif isinstance(widths, list): if len(widths) != len(columns): self._warn( f"{len(widths)} widths given for {len(columns)} columns, " "using equal widths." ) widths = "grid" else: table["classes"].append("colwidths-given") if widths == "auto": # content based hints for writers that need widths (e.g. text) table["enum_auto_widths"] = True widths = [ min( max(len(_text(cell)) for cell in [header, *(r[idx] for r in rows)]), _MAX_AUTO_WIDTH, ) for idx, header in enumerate(headers) ] elif not isinstance(widths, list): widths = [100 // len(columns)] * len(columns) tgroup = nodes.tgroup(cols=len(columns)) table += tgroup for width in widths: tgroup += nodes.colspec(colwidth=max(width, 1)) thead = nodes.thead() tgroup += thead thead += self._row([nodes.paragraph(header, header) for header in headers]) # enum data should be rendered verbatim tbody = nodes.tbody(support_smartquotes=False) tgroup += tbody for row in rows: tbody += self._row( [ _cell(cell, literal=column == NAME) for cell, column in zip(row, columns) ] ) return table @staticmethod def _row(cells: list[nodes.Node | list[nodes.Node]]) -> nodes.row: row = nodes.row() for cell in cells: entry = nodes.entry() entry += cell row += entry return row
def _text(cell: Cell) -> str: if isinstance(cell, str): return cell if isinstance(cell, list): return "\n\n".join(node.astext() for node in cell) return cell.astext() def _json(value: t.Any, text: str) -> t.Any: """ Convert a raw cell value to JSON. Top level values that are not natively serializable use the cell's display text (respecting any custom formatter). """ return to_json_value( value, lambda item: text if item is value else format_value(item) ) def _cell(cell: Cell, literal: bool = False) -> nodes.Node | list[nodes.Node]: if isinstance(cell, list): return cell if isinstance(cell, str): cell = nodes.literal(cell, cell) if literal else nodes.Text(cell) elif isinstance(cell, nodes.Body): return cell return nodes.paragraph("", "", cell) # -- Writers ---------------------------------------------------------------------- def visit_downloads_html(self: SphinxTranslator, node: enum_table_downloads) -> None: builder = self.builder base_uri = builder.get_target_uri( getattr(builder, "current_docname", None) or builder.config.root_doc ) links = [] for fmt, content in node["files"]: filename = f"{node['basename']}.{fmt}" digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16] target = f"{DOWNLOAD_DIR}/{digest}/{filename}" path = Path(builder.outdir) / target ensuredir(path.parent) path.write_text(content, encoding="utf-8") links.append( f'<a class="enum-table-download reference download" ' f'href="{escape(relative_uri(base_uri, target))}" ' f'download="{escape(filename)}" ' f'title="Download {escape(node["basename"])} as {fmt.upper()}">' f"{_DOWNLOAD_ICON}<span>{fmt.upper()}</span></a>" ) self.body.append( # type: ignore[attr-defined] f'<div class="enum-table-downloads">{"".join(links)}</div>' ) raise nodes.SkipNode def visit_legend_html(self: SphinxTranslator, node: enum_table_legend) -> None: """ Link the preceding table to this legend with ``aria-describedby``. The table has already been written, so its start tag is patched in the output rather than overriding the table visitor (which themes may customize). """ table = node.parent[node.parent.index(node) - 1] if node.parent else None if isinstance(table, nodes.table) and table["ids"] and node["ids"]: table_id = f' id="{table["ids"][0]}"' for idx in range(len(self.body) - 1, -1, -1): # type: ignore[attr-defined] chunk = self.body[idx] # type: ignore[attr-defined] if chunk.startswith("<table") and table_id in chunk: self.body[idx] = chunk.replace( # type: ignore[attr-defined] "<table", f'<table aria-describedby="{escape(node["ids"][0])}"', 1, ) break self.visit_definition_list(node) # type: ignore[attr-defined] def depart_legend_html(self: SphinxTranslator, node: enum_table_legend) -> None: self.depart_definition_list(node) # type: ignore[attr-defined] def _remove_downloads(app: Sphinx, doctree: nodes.document, docname: str) -> None: """Downloads are only supported by the html builders (excluding epub).""" if app.builder.format != "html" or app.builder.name.startswith("epub"): for node in list(doctree.findall(enum_table_downloads)): node.parent.remove(node) # sphinx's latex writer switches to longtable when a table has more than this many rows _LATEX_LONGTABLE_ROWS = 30 # width hint adjustments, in characters, for typeset latex columns _LATEX_LITERAL_SCALE = 1.3 _LATEX_CELL_PADDING = 3 def _wrap_latex_longtables(app: Sphinx, doctree: nodes.document, docname: str) -> None: """ Sphinx renders longtables with non-wrapping ``l`` columns unless column widths are given, and only Sphinx 9+ caps their cells so long text wraps. For latex builds, give large enum tables proportional widths from the content based hints so their cells wrap on every supported Sphinx version. """ if app.builder.format != "latex": return for table in doctree.findall(nodes.table): classes = table["classes"] if "enum-table" not in classes or "colwidths-given" in classes: continue rows = list(table.findall(nodes.row)) if len(rows) > _LATEX_LONGTABLE_ROWS or "longtable" in classes: classes.append("colwidths-given") if table.get("enum_auto_widths"): _latex_width_hints(table, rows) def _latex_width_hints(table: nodes.table, rows: list[nodes.row]) -> None: """ Adjust the character count width hints for typesetting: every column pays for its cell padding and literal (monospace) columns need more room per character. """ body = [row for row in rows if isinstance(row.parent, nodes.tbody)] for idx, colspec in enumerate(table.findall(nodes.colspec)): cells = [row[idx] for row in body if idx < len(row)] literal = bool(cells) and all( next(iter(cell.findall(nodes.literal)), None) is not None for cell in cells ) width = colspec["colwidth"] * (_LATEX_LITERAL_SCALE if literal else 1) colspec["colwidth"] = round(width) + _LATEX_CELL_PADDING def _check_download_config(app: Sphinx, config: Config) -> None: try: download_formats(config.enum_table_download) except (TypeError, ValueError) as err: raise ConfigError(f"Invalid enum_table_download: {err}") from err def setup(app: Sphinx) -> None: app.add_node( enum_table_downloads, html=(visit_downloads_html, None), ) # other builders fall back to their definition_list visitors app.add_node(enum_table_legend, html=(visit_legend_html, depart_legend_html)) app.add_directive("enum-table", EnumTableDirective) app.connect("doctree-resolved", _remove_downloads) app.connect("doctree-resolved", _wrap_latex_longtables) app.add_config_value( "enum_table_download", False, "env", # frozenset/set must not be listed, sphinx converts sequences to frozensets # when they are, losing the format order types=(bool, list, tuple, str, type(None)), ) app.connect("config-inited", _check_download_config) app.add_config_value("enum_table_formatter", None, "env")