From 6e18601b8d10cc7321e9b4cc6fac9372f64354d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 14:17:56 -0500 Subject: [PATCH 01/14] feat(sphinx-autodoc-fastmcp): Add FastMCP tool documentation extension why: Reusable Sphinx extension for FastMCP tool cards, badges, directives, and cross-reference roles (extracted from libtmux-mcp local _ext). what: - New package with collector, directives (desc-based cards), roles, transforms - Unit tests under tests/ext/fastmcp/ --- packages/sphinx-autodoc-fastmcp/README.md | 41 +++ .../sphinx-autodoc-fastmcp/pyproject.toml | 40 +++ .../src/sphinx_autodoc_fastmcp/__init__.py | 118 +++++++ .../src/sphinx_autodoc_fastmcp/_badges.py | 129 +++++++ .../src/sphinx_autodoc_fastmcp/_collector.py | 187 ++++++++++ .../src/sphinx_autodoc_fastmcp/_css.py | 37 ++ .../src/sphinx_autodoc_fastmcp/_directives.py | 238 +++++++++++++ .../src/sphinx_autodoc_fastmcp/_models.py | 42 +++ .../src/sphinx_autodoc_fastmcp/_parsing.py | 327 ++++++++++++++++++ .../src/sphinx_autodoc_fastmcp/_roles.py | 74 ++++ .../_static/css/sphinx_autodoc_fastmcp.css | 205 +++++++++++ .../src/sphinx_autodoc_fastmcp/_transforms.py | 160 +++++++++ .../src/sphinx_autodoc_fastmcp/py.typed | 0 tests/ext/fastmcp/test_fastmcp.py | 57 +++ 14 files changed, 1655 insertions(+) create mode 100644 packages/sphinx-autodoc-fastmcp/README.md create mode 100644 packages/sphinx-autodoc-fastmcp/pyproject.toml create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py create mode 100644 packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/py.typed create mode 100644 tests/ext/fastmcp/test_fastmcp.py diff --git a/packages/sphinx-autodoc-fastmcp/README.md b/packages/sphinx-autodoc-fastmcp/README.md new file mode 100644 index 0000000..3df1889 --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/README.md @@ -0,0 +1,41 @@ +# sphinx-autodoc-fastmcp + +Sphinx extension that documents **FastMCP** tools with card-style `desc` layouts (aligned with `sphinx-autodoc-api-style`), safety badges, parameter tables, and cross-reference roles. + +## Features + +- **`fastmcp-tool`**: Renders a tool entry as an `mcp` domain `desc` (definition list card) plus a section for ToC and `{ref}` labels. +- **`fastmcp-tool-input`**: Parameter table for a tool (place after prose in MyST). +- **`fastmcp-toolsummary`**: Summary tables grouped by safety tier. +- **Roles**: `:tool:`, `:toolref:`, `:toolicon` / `:tooliconl` / `:tooliconr` / `:tooliconil` / `:tooliconir:`, `:badge:` + +## Configuration + +In `conf.py` after `sphinx_autodoc_fastmcp` is listed in `extensions`: + +```python +fastmcp_tool_modules = [ + "myproject.tools.server_tools", + "myproject.tools.session_tools", +] +fastmcp_area_map = { + "server_tools": "sessions", + "session_tools": "sessions", +} +fastmcp_model_module = "myproject.models" +fastmcp_model_classes = {"SessionInfo", "WindowInfo"} +fastmcp_section_badge_map = {"Inspect": "readonly", "Act": "mutating", "Destroy": "destructive"} +fastmcp_section_badge_pages = {"tools/index", "index"} +fastmcp_collector_mode = "register" # or "introspect" +``` + +See the package docstrings and `sphinx_autodoc_fastmcp.setup()` for defaults. + +## Dependencies + +- Python 3.10+ +- Sphinx + +## License + +MIT diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml new file mode 100644 index 0000000..6ac619d --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-autodoc-fastmcp" +version = "0.0.1a5" +description = "Sphinx extension for documenting FastMCP tools (cards, badges, cross-refs)" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] +dependencies = [ + "sphinx", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_autodoc_fastmcp"] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py new file mode 100644 index 0000000..3575d1f --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -0,0 +1,118 @@ +"""Sphinx extension for documenting FastMCP tools (cards, badges, cross-refs). + +Examples +-------- +>>> from sphinx_autodoc_fastmcp import setup +>>> callable(setup) +True +""" + +from __future__ import annotations + +import logging +import pathlib +import typing as t + +from docutils import nodes +from sphinx.application import Sphinx + +from sphinx_autodoc_fastmcp._badges import ( + depart_abbreviation_html, + visit_abbreviation_html, +) +from sphinx_autodoc_fastmcp._collector import collect_tools +from sphinx_autodoc_fastmcp._directives import ( + FastMCPToolDirective, + FastMCPToolInputDirective, + FastMCPToolSummaryDirective, +) +from sphinx_autodoc_fastmcp._roles import ( + _tool_role, + _toolicon_role, + _tooliconil_role, + _tooliconir_role, + _tooliconl_role, + _tooliconr_role, + _toolref_role, +) +from sphinx_autodoc_fastmcp._transforms import ( + add_section_badges, + badge_role, + register_tool_labels, + resolve_tool_refs, +) + +__all__ = [ + "setup", +] + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +_EXTENSION_VERSION = "0.0.1a5" + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the ``sphinx_autodoc_fastmcp`` extension. + + Parameters + ---------- + app : Sphinx + Sphinx application. + + Returns + ------- + dict[str, Any] + Extension metadata (version, parallel flags). + + Examples + -------- + >>> from sphinx_autodoc_fastmcp import setup + >>> callable(setup) + True + """ + app.add_config_value("fastmcp_tool_modules", [], "env") + app.add_config_value("fastmcp_area_map", {}, "env") + app.add_config_value("fastmcp_model_module", "", "env") + app.add_config_value("fastmcp_model_classes", (), "env") + app.add_config_value("fastmcp_section_badge_map", {}, "env") + app.add_config_value("fastmcp_section_badge_pages", (), "env") + app.add_config_value("fastmcp_collector_mode", "register", "env") + + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/sphinx_autodoc_fastmcp.css") + + app.add_node( + nodes.abbreviation, + override=True, + html=(visit_abbreviation_html, depart_abbreviation_html), + ) + + app.connect("builder-inited", collect_tools) + app.connect("doctree-read", register_tool_labels) + app.connect("doctree-resolved", add_section_badges) + app.connect("doctree-resolved", resolve_tool_refs) + + app.add_role("tool", _tool_role) + app.add_role("toolref", _toolref_role) + app.add_role("toolicon", _toolicon_role) + app.add_role("tooliconl", _tooliconl_role) + app.add_role("tooliconr", _tooliconr_role) + app.add_role("tooliconil", _tooliconil_role) + app.add_role("tooliconir", _tooliconir_role) + app.add_role("badge", badge_role) + + app.add_directive("fastmcp-tool", FastMCPToolDirective) + app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) + app.add_directive("fastmcp-toolsummary", FastMCPToolSummaryDirective) + + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py new file mode 100644 index 0000000..bf272b2 --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -0,0 +1,129 @@ +"""Badge nodes and HTML visitors for sphinx_autodoc_fastmcp.""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from sphinx.writers.html5 import HTML5Translator + +from sphinx_autodoc_fastmcp._css import _CSS + +_SAFETY_LABELS = ("readonly", "mutating", "destructive") + +_SAFETY_TOOLTIPS: dict[str, str] = { + "readonly": "Read-only — does not modify external state", + "mutating": "Mutating — creates or modifies objects", + "destructive": "Destructive — may remove data; not reversible", +} + +_TYPE_TOOLTIP = "MCP tool" + + +def build_safety_badge( + safety: str, + *, + icon_only: bool = False, +) -> nodes.abbreviation: + """Build a single safety tier badge as an ``abbreviation`` node. + + Parameters + ---------- + safety : str + One of ``readonly``, ``mutating``, ``destructive``. + icon_only : bool + When True, use a narrow non-breaking space for icon-only layouts. + + Returns + ------- + nodes.abbreviation + + Examples + -------- + >>> b = build_safety_badge("readonly") + >>> b.astext() + 'readonly' + """ + label = safety if safety in _SAFETY_LABELS else safety + text = "\u00a0" if icon_only else label + classes = [_CSS.BADGE, _CSS.BADGE_SAFETY] + if safety in _SAFETY_LABELS: + classes.append(_CSS.safety_class(safety)) + if icon_only: + classes.append(f"{_CSS.PREFIX}-badge--icon-only") + abbr = nodes.abbreviation( + text, + text, + explanation=_SAFETY_TOOLTIPS.get(safety, f"Safety: {safety}"), + classes=classes, + ) + abbr["tabindex"] = "0" + return abbr + + +def build_type_tool_badge() -> nodes.abbreviation: + """Rightmost type badge labeling the entry as an MCP tool.""" + abbr = nodes.abbreviation( + "tool", + "tool", + explanation=_TYPE_TOOLTIP, + classes=[_CSS.BADGE, _CSS.BADGE_TYPE, _CSS.TYPE_TOOL], + ) + abbr["tabindex"] = "0" + return abbr + + +def build_tool_badge_group(safety: str) -> nodes.inline: + """Badge group for a tool signature: safety tier + type ``tool``. + + Parameters + ---------- + safety : str + Safety tier name. + + Returns + ------- + nodes.inline + Container with class ``smf-badge-group``. + + Examples + -------- + >>> g = build_tool_badge_group("readonly") + >>> _CSS.BADGE_GROUP in g["classes"] + True + """ + group = nodes.inline(classes=[_CSS.BADGE_GROUP]) + safety_badge = build_safety_badge(safety) + type_badge = build_type_tool_badge() + group += safety_badge + group += nodes.Text(" ") + group += type_badge + return group + + +def build_toolbar(safety: str) -> nodes.inline: + """Toolbar container (signature right side): badge group only.""" + toolbar = nodes.inline(classes=[_CSS.TOOLBAR]) + toolbar += build_tool_badge_group(safety) + return toolbar + + +def visit_abbreviation_html( + self: HTML5Translator, + node: nodes.abbreviation, +) -> None: + """Emit ```` with ``tabindex`` when present (keyboard tooltips).""" + attrs: dict[str, t.Any] = {} + if node.get("explanation"): + attrs["title"] = node["explanation"] + if node.get("tabindex"): + attrs["tabindex"] = node["tabindex"] + self.body.append(self.starttag(node, "abbr", "", **attrs)) + + +def depart_abbreviation_html( + self: HTML5Translator, + node: nodes.abbreviation, +) -> None: + """Close the ```` tag.""" + self.body.append("") diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py new file mode 100644 index 0000000..214a748 --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -0,0 +1,187 @@ +"""Collect FastMCP tool metadata at Sphinx build time.""" + +from __future__ import annotations + +import importlib +import inspect +import logging +import typing as t + +from sphinx.application import Sphinx + +from sphinx_autodoc_fastmcp._models import ToolInfo +from sphinx_autodoc_fastmcp._parsing import extract_params, format_annotation + +logger = logging.getLogger(__name__) + +TAG_READONLY = "readonly" +TAG_MUTATING = "mutating" +TAG_DESTRUCTIVE = "destructive" + + +class ToolCollector: + """Mock FastMCP server that captures tool registrations.""" + + def __init__( + self, + *, + area_map: dict[str, str], + ) -> None: + self.tools: list[ToolInfo] = [] + self._current_module: str = "" + self._area_map = area_map + + def tool( + self, + title: str = "", + annotations: dict[str, bool] | None = None, + tags: set[str] | None = None, + ) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]: + """Match ``FastMCP.tool()`` decorator behavior for capture.""" + annotations = annotations or {} + tags = tags or set() + + def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + if TAG_DESTRUCTIVE in tags: + safety = "destructive" + elif TAG_MUTATING in tags: + safety = "mutating" + else: + safety = "readonly" + + module_name = self._current_module + area = self._area_map.get( + module_name, + module_name.replace("_tools", ""), + ) + + self.tools.append( + ToolInfo( + name=func.__name__, + title=title or func.__name__.replace("_", " ").title(), + module_name=module_name, + area=area, + safety=safety, + annotations=annotations, + func=func, + docstring=func.__doc__ or "", + params=extract_params(func), + return_annotation=format_annotation( + inspect.signature(func).return_annotation, + ), + ), + ) + return func + + return decorator + + +def _tool_from_callable( + func: t.Callable[..., t.Any], + *, + module_name: str, + area_map: dict[str, str], +) -> ToolInfo | None: + """Build ``ToolInfo`` from a decorated function (``__fastmcp__``).""" + meta = getattr(func, "__fastmcp__", None) + if meta is None: + return None + tags = getattr(meta, "tags", None) or set() + if not isinstance(tags, set): + tags = set(tags) if tags else set() + if TAG_DESTRUCTIVE in tags: + safety = "destructive" + elif TAG_MUTATING in tags: + safety = "mutating" + else: + safety = "readonly" + area = area_map.get(module_name, module_name.replace("_tools", "")) + name = getattr(meta, "name", None) or func.__name__ + title = getattr(meta, "title", None) or name.replace("_", " ").title() + annotations = getattr(meta, "annotations", None) + ann_dict: dict[str, bool] = {} + if annotations is not None: + for field in ( + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", + ): + val = getattr(annotations, field, None) + if isinstance(val, bool): + ann_dict[field] = val + return ToolInfo( + name=name, + title=title, + module_name=module_name, + area=area, + safety=safety, + annotations=ann_dict, + func=func, + docstring=func.__doc__ or "", + params=extract_params(func), + return_annotation=format_annotation(inspect.signature(func).return_annotation), + ) + + +def collect_tools(app: Sphinx) -> None: + """Populate ``app.env.fastmcp_tools`` from configured modules.""" + modules: list[str] = list(app.config.fastmcp_tool_modules) + area_map: dict[str, str] = dict(app.config.fastmcp_area_map) + mode = str(app.config.fastmcp_collector_mode) + if mode not in ("register", "introspect"): + logger.warning( + "sphinx_autodoc_fastmcp: unknown fastmcp_collector_mode %r; using 'register'", + mode, + ) + mode = "register" + + if not modules: + logger.warning( + "sphinx_autodoc_fastmcp: fastmcp_tool_modules is empty; no tools collected", + ) + app.env.fastmcp_tools = {} # type: ignore[attr-defined] + return + + collector_tools: list[ToolInfo] = [] + + if mode == "register": + collector = ToolCollector(area_map=area_map) + for dotted in modules: + mod_suffix = dotted.split(".")[-1] + collector._current_module = mod_suffix + try: + mod = importlib.import_module(dotted) + if hasattr(mod, "register"): + mod.register(collector) + except Exception: + logger.warning( + "sphinx_autodoc_fastmcp: failed to load tool module %s", + dotted, + exc_info=True, + ) + collector_tools = collector.tools + else: + for dotted in modules: + mod_suffix = dotted.split(".")[-1] + try: + mod = importlib.import_module(dotted) + except Exception: + logger.warning( + "sphinx_autodoc_fastmcp: failed to import %s", + dotted, + exc_info=True, + ) + continue + for _name, obj in inspect.getmembers(mod): + if not callable(obj): + continue + info = _tool_from_callable( + obj, + module_name=mod_suffix, + area_map=area_map, + ) + if info is not None: + collector_tools.append(info) + + app.env.fastmcp_tools = {tool.name: tool for tool in collector_tools} # type: ignore[attr-defined] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py new file mode 100644 index 0000000..a88ce45 --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -0,0 +1,37 @@ +"""CSS class name constants for sphinx_autodoc_fastmcp. + +Examples +-------- +>>> _CSS.PREFIX +'smf' + +>>> _CSS.BADGE_GROUP +'smf-badge-group' + +>>> _CSS.TOOLBAR +'smf-toolbar' +""" + +from __future__ import annotations + + +class _CSS: + """CSS class name constants (``smf-`` = sphinx autodoc fastmcp).""" + + PREFIX = "smf" + BADGE_GROUP = f"{PREFIX}-badge-group" + BADGE = f"{PREFIX}-badge" + BADGE_TYPE = f"{PREFIX}-badge--type" + BADGE_SAFETY = f"{PREFIX}-badge--safety" + TOOLBAR = f"{PREFIX}-toolbar" + SECTION_TITLE_HIDDEN = f"{PREFIX}-visually-hidden" + TYPE_TOOL = f"{PREFIX}-type-tool" + + SAFETY_READONLY = f"{PREFIX}-safety-readonly" + SAFETY_MUTATING = f"{PREFIX}-safety-mutating" + SAFETY_DESTRUCTIVE = f"{PREFIX}-safety-destructive" + + @staticmethod + def safety_class(safety: str) -> str: + """Return safety modifier class for badge styling.""" + return f"{_CSS.PREFIX}-safety-{safety}" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py new file mode 100644 index 0000000..eba8573 --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -0,0 +1,238 @@ +"""Sphinx directives for FastMCP tool documentation.""" + +from __future__ import annotations + +from docutils import nodes +from sphinx import addnodes +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_fastmcp._badges import build_toolbar +from sphinx_autodoc_fastmcp._css import _CSS +from sphinx_autodoc_fastmcp._models import ParamInfo, ToolInfo +from sphinx_autodoc_fastmcp._parsing import ( + extract_enum_values as extract_enum_values_from_type, + first_paragraph, + make_para, + make_table, + make_type_cell_smart, + make_type_xref, + parse_rst_inline, +) + + +class FastMCPToolDirective(SphinxDirective): + """Autodocument one MCP tool: section (ToC/labels) + ``desc`` card.""" + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build section + ``mcp`` ``desc`` nodes for one tool.""" + arg = self.arguments[0] + func_name = arg.split(".")[-1] if "." in arg else arg + + tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) + tool = tools.get(func_name) + + if tool is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-tool: tool '{func_name}' not found. " + f"Available: {', '.join(sorted(tools.keys()))}", + line=self.lineno, + ), + ] + + return self._build_tool_section(tool) + + def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: + """Build visually hidden section title + card ``desc``.""" + document = self.state.document + section_id = tool.name.replace("_", "-") + + section = nodes.section() + section["ids"].append(section_id) + document.note_explicit_target(section) + + title_node = nodes.title("", "") + title_node += nodes.literal("", tool.name) + title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) + section += title_node + + desc_node = addnodes.desc() + desc_node["domain"] = "mcp" + desc_node["objtype"] = "tool" + desc_node["desctype"] = "tool" + desc_node["classes"].extend(["mcp", "tool"]) + self.set_source_info(desc_node) + + signode = addnodes.desc_signature("", "") + self.set_source_info(signode) + signode += addnodes.desc_name("", tool.name) + signode += build_toolbar(tool.safety) + + content_node = addnodes.desc_content("") + self.set_source_info(content_node) + + first_para = first_paragraph(tool.docstring) + content_node += parse_rst_inline(first_para, self.state, self.lineno) + + if tool.return_annotation: + returns_para = nodes.paragraph("") + returns_para += nodes.strong("", "Returns: ") + type_para = make_type_xref( + tool.return_annotation, + model_module=str(self.config.fastmcp_model_module), + model_classes=frozenset(self.config.fastmcp_model_classes), + ) + for child in type_para.children: + returns_para += child.deepcopy() + content_node += returns_para + + desc_node += signode + desc_node += content_node + section += desc_node + + return [section] + + +class FastMCPToolInputDirective(SphinxDirective): + """Emit the parameter table for a tool.""" + + required_arguments = 1 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build parameter table nodes.""" + arg = self.arguments[0] + func_name = arg.split(".")[-1] if "." in arg else arg + + tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) + tool = tools.get(func_name) + + if tool is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-tool-input: tool '{func_name}' not found.", + line=self.lineno, + ), + ] + + result: list[nodes.Node] = [] + + if tool.params: + result.append(make_para(nodes.strong("", "Parameters"))) + headers = ["Parameter", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for p in tool.params: + desc_node = self._build_description(p) + + type_cell, is_enum = make_type_cell_smart(p.type_str) + + if is_enum and p.type_str: + enum_values = extract_enum_values_from_type(p.type_str) + if enum_values: + desc_node += nodes.Text(" One of: ") + for i, val in enumerate(enum_values): + if i > 0: + desc_node += nodes.Text(", ") + desc_node += nodes.literal("", val) + desc_node += nodes.Text(".") + + default_cell: str | nodes.Node = "—" + if p.default and p.default != "None": + default_cell = make_para(nodes.literal("", p.default)) + + rows.append( + [ + make_para(nodes.literal("", p.name)), + type_cell, + "yes" if p.required else "no", + default_cell, + desc_node, + ], + ) + result.append( + make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]), + ) + + return result + + def _build_description(self, p: ParamInfo) -> nodes.paragraph: + """Build description paragraph with optional RST inline markup.""" + if p.description: + return parse_rst_inline( + p.description, + self.state, + self.lineno, + ) + return nodes.paragraph("", "—") + + +class FastMCPToolSummaryDirective(SphinxDirective): + """Summary tables of tools grouped by safety tier.""" + + required_arguments = 0 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build tier sections with tables.""" + tools: dict[str, ToolInfo] = getattr(self.env, "fastmcp_tools", {}) + + if not tools: + return [ + self.state.document.reporter.warning( + "fastmcp-toolsummary: no tools found.", + line=self.lineno, + ), + ] + + groups: dict[str, list[ToolInfo]] = { + "readonly": [], + "mutating": [], + "destructive": [], + } + for tool in tools.values(): + groups.setdefault(tool.safety, []).append(tool) + + result_nodes: list[nodes.Node] = [] + + tier_order = [ + ("readonly", "Inspect", "Read state without changing anything."), + ("mutating", "Act", "Create or modify objects."), + ("destructive", "Destroy", "Remove objects; not reversible."), + ] + + for safety, label, desc in tier_order: + tier_tools = groups.get(safety, []) + if not tier_tools: + continue + + section = nodes.section() + section["ids"].append(label.lower()) + self.state.document.note_explicit_target(section) + section += nodes.title("", label) + section += nodes.paragraph("", desc) + + headers = ["Tool", "Description"] + rows: list[list[str | nodes.Node]] = [] + for tool in sorted(tier_tools, key=lambda x: x.name): + first_line = first_paragraph(tool.docstring) + ref = nodes.reference("", "", internal=True) + ref["refuri"] = f"{tool.area}/#{tool.name.replace('_', '-')}" + ref += nodes.literal("", tool.name) + rows.append( + [ + make_para(ref), + parse_rst_inline(first_line, self.state, self.lineno), + ], + ) + section += make_table(headers, rows, col_widths=[30, 70]) + + result_nodes.append(section) + + return result_nodes diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py new file mode 100644 index 0000000..031f2f6 --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_models.py @@ -0,0 +1,42 @@ +"""Data models for FastMCP tool documentation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + + +@dataclass +class ParamInfo: + """Extracted parameter information for a tool.""" + + name: str + type_str: str + required: bool + default: str + description: str + + +@dataclass +class ToolInfo: + """Collected metadata for a single MCP tool.""" + + name: str + title: str + module_name: str + area: str + safety: str + annotations: dict[str, bool] + func: t.Callable[..., t.Any] + docstring: str + params: list[ParamInfo] + return_annotation: str + + +@dataclass +class ResourceInfo: + """Placeholder for future FastMCP resource documentation.""" + + name: str + uri_template: str = "" + module_name: str = "" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py new file mode 100644 index 0000000..f1f2bba --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_parsing.py @@ -0,0 +1,327 @@ +"""Docstring and signature parsing for FastMCP tools.""" + +from __future__ import annotations + +import inspect +import re +import typing as t + +from docutils import nodes +from sphinx import addnodes + +from sphinx_autodoc_fastmcp._models import ParamInfo + + +def parse_numpy_params(docstring: str) -> dict[str, str]: + """Extract parameter descriptions from NumPy-style docstring. + + Parameters + ---------- + docstring : str + Full docstring text. + + Returns + ------- + dict[str, str] + Mapping parameter name to description. + + Examples + -------- + >>> parse_numpy_params("") + {} + """ + params: dict[str, str] = {} + if not docstring: + return params + + lines = docstring.split("\n") + in_params = False + current_param: str | None = None + current_desc: list[str] = [] + + for line in lines: + stripped = line.strip() + indent = len(line) - len(line.lstrip()) + + if stripped == "Parameters": + in_params = True + continue + if in_params and stripped.startswith("---"): + continue + if in_params and stripped in ( + "Returns", + "Raises", + "Notes", + "Examples", + "See Also", + ): + if current_param: + params[current_param] = " ".join(current_desc).strip() + break + if in_params and not stripped: + continue + + if in_params: + param_match = re.match(r"^(\w+)\s*:", stripped) + if param_match and indent == 0: + if current_param: + params[current_param] = " ".join(current_desc).strip() + current_param = param_match.group(1) + current_desc = [] + elif current_param and indent > 0: + current_desc.append(stripped) + + if current_param: + params[current_param] = " ".join(current_desc).strip() + + return params + + +def first_paragraph(docstring: str) -> str: + """Extract the first paragraph from a docstring. + + Examples + -------- + >>> first_paragraph("Hello.") + 'Hello.' + """ + if not docstring: + return "" + paragraphs = docstring.strip().split("\n\n") + return paragraphs[0].strip().replace("\n", " ") + + +def format_annotation(ann: t.Any, *, strip_none: bool = False) -> str: + """Format a type annotation as a readable string.""" + if ann is inspect.Parameter.empty: + return "" + if isinstance(ann, str): + result = ann + result = re.sub( + r"(?:t\.)?Literal\[([^\]]+)\]", + lambda m: m.group(1), + result, + ) + if strip_none: + result = re.sub(r"\s*\|\s*None\b", "", result).strip() + return result + if hasattr(ann, "__name__"): + return str(ann.__name__) + return str(ann).replace("typing.", "") + + +def extract_params(func: t.Callable[..., t.Any]) -> list[ParamInfo]: + """Extract parameter info from function signature and docstring.""" + sig = inspect.signature(func) + doc_params = parse_numpy_params(func.__doc__ or "") + params: list[ParamInfo] = [] + + for name, param in sig.parameters.items(): + is_optional = param.default != inspect.Parameter.empty + type_str = format_annotation( + param.annotation, + strip_none=is_optional, + ) + + if is_optional: + if param.default is None: + default_str = "None" + elif isinstance(param.default, bool): + default_str = str(param.default) + elif isinstance(param.default, str): + default_str = repr(param.default) + else: + default_str = str(param.default) + required = False + else: + default_str = "" + required = True + + params.append( + ParamInfo( + name=name, + type_str=type_str, + required=required, + default=default_str, + description=doc_params.get(name, ""), + ), + ) + + return params + + +def extract_enum_values(type_str: str) -> list[str]: + """Extract individual enum values from a Literal type string.""" + parts = [p.strip() for p in type_str.split("|")] + values: list[str] = [] + for part in parts: + for sub in part.split(","): + sub = sub.strip() + if re.match(r"^'[^']*'$", sub): + values.append(sub) + return values + + +def make_literal(text: str) -> nodes.literal: + """Create an inline code literal node.""" + return nodes.literal("", text) + + +def single_type_xref( + name: str, + *, + model_module: str, + model_classes: frozenset[str], +) -> addnodes.pending_xref: + """Create a ``pending_xref`` for a single type name.""" + target = f"{model_module}.{name}" if name in model_classes else name + return addnodes.pending_xref( + "", + nodes.literal("", name), + refdomain="py", + reftype="class", + reftarget=target, + ) + + +def make_type_xref( + type_str: str, + *, + model_module: str, + model_classes: frozenset[str], +) -> nodes.paragraph: + """Render a return type annotation with cross-reference links.""" + para = nodes.paragraph("") + m = re.match(r"^(list|set|tuple)\[(.+)\]$", type_str) + if m: + container, inner = m.group(1), m.group(2) + para += single_type_xref( + container, + model_module=model_module, + model_classes=model_classes, + ) + para += nodes.Text("[") + para += single_type_xref( + inner, + model_module=model_module, + model_classes=model_classes, + ) + para += nodes.Text("]") + else: + para += single_type_xref( + type_str, + model_module=model_module, + model_classes=model_classes, + ) + return para + + +def make_para(*children: nodes.Node | str) -> nodes.paragraph: + """Create a paragraph from mixed text and node children.""" + para = nodes.paragraph("") + for child in children: + if isinstance(child, str): + para += nodes.Text(child) + else: + para += child + return para + + +def make_type_cell(type_str: str) -> nodes.paragraph: + """Render a type annotation as comma-separated code literals.""" + parts = [p.strip() for p in type_str.split("|")] + + expanded: list[str] = [] + for part in parts: + if re.match(r"^'[^']*'(\s*,\s*'[^']*')+$", part): + expanded.extend(v.strip() for v in part.split(",")) + else: + expanded.append(part) + + para = nodes.paragraph("") + for i, part in enumerate(expanded): + if i > 0: + para += nodes.Text(", ") + para += nodes.literal("", part) + return para + + +def make_type_cell_smart( + type_str: str, +) -> tuple[nodes.paragraph | str, bool]: + """Render a type annotation, detecting enum-only types.""" + if not type_str: + return ("", False) + + parts = [p.strip() for p in type_str.split("|")] + + all_quoted = all(re.match(r"^'[^']*'$", p) for p in parts) + if not all_quoted and len(parts) == 1: + sub = [s.strip() for s in parts[0].split(",")] + all_quoted = len(sub) > 1 and all(re.match(r"^'[^']*'$", s) for s in sub) + + if all_quoted: + return (make_para(make_literal("enum")), True) + + return (make_type_cell(type_str), False) + + +def parse_rst_inline( + text: str, + state: t.Any, + lineno: int, +) -> nodes.paragraph: + """Parse RST inline markup into a paragraph node.""" + parsed_nodes, _messages = state.inline_text(text, lineno) + para = nodes.paragraph("") + para += parsed_nodes + return para + + +def make_table( + headers: list[str], + rows: list[list[str | nodes.Node]], + col_widths: list[int] | None = None, +) -> nodes.table: + """Build a docutils table node from headers and rows. + + Examples + -------- + >>> t = make_table(["A"], [["x"]]) + >>> isinstance(t, nodes.table) + True + """ + ncols = len(headers) + if col_widths is None: + col_widths = [100 // ncols] * ncols + + table = nodes.table("") + tgroup = nodes.tgroup("", cols=ncols) + table += tgroup + + for width in col_widths: + tgroup += nodes.colspec("", colwidth=width) + + thead = nodes.thead("") + header_row = nodes.row("") + for header in headers: + entry = nodes.entry("") + entry += nodes.paragraph("", header) + header_row += entry + thead += header_row + tgroup += thead + + tbody = nodes.tbody("") + for row_data in rows: + row = nodes.row("") + for cell in row_data: + entry = nodes.entry("") + if isinstance(cell, nodes.Node): + entry += cell + else: + entry += nodes.paragraph("", str(cell)) + row += entry + tbody += row + tgroup += tbody + + return table diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py new file mode 100644 index 0000000..40c3e4c --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_roles.py @@ -0,0 +1,74 @@ +"""Inline roles for FastMCP tool cross-references.""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + + +class _tool_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): + """Placeholder resolved at ``doctree-resolved``.""" + + +def _tool_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Role ``:tool:`name``` → link + badge (resolved later).""" + target = text.strip().replace("_", "-") + node = _tool_ref_placeholder(rawtext, reftarget=target, show_badge=True) + return [node], [] + + +def _toolref_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Role ``:toolref:`name``` → link without badge.""" + target = text.strip().replace("_", "-") + node = _tool_ref_placeholder(rawtext, reftarget=target, show_badge=False) + return [node], [] + + +def _make_toolicon_role( + icon_pos: str, +) -> t.Callable[..., tuple[list[nodes.Node], list[nodes.system_message]]]: + """Create an icon-only tool reference role callable.""" + + def role_fn( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, + ) -> tuple[list[nodes.Node], list[nodes.system_message]]: + target = text.strip().replace("_", "-") + node = _tool_ref_placeholder( + rawtext, + reftarget=target, + show_badge=False, + icon_pos=icon_pos, + ) + return [node], [] + + return role_fn + + +_toolicon_role = _make_toolicon_role("left") +_tooliconl_role = _make_toolicon_role("left") +_tooliconr_role = _make_toolicon_role("right") +_tooliconil_role = _make_toolicon_role("inline-left") +_tooliconir_role = _make_toolicon_role("inline-right") diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css new file mode 100644 index 0000000..53cbba2 --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -0,0 +1,205 @@ +/* sphinx_autodoc_fastmcp — FastMCP tool cards (dl.mcp.tool) + smf badges */ + +:root { + --smf-badge-font-size: 0.67rem; + --smf-badge-padding-v: 0.16rem; + --smf-badge-border-w: 1px; + + --smf-safety-readonly-bg: #dcfce7; + --smf-safety-readonly-fg: #166534; + --smf-safety-readonly-border: #22c55e; + + --smf-safety-mutating-bg: #fef3c7; + --smf-safety-mutating-fg: #92400e; + --smf-safety-mutating-border: #f59e0b; + + --smf-safety-destructive-bg: #fee2e2; + --smf-safety-destructive-fg: #991b1b; + --smf-safety-destructive-border: #ef4444; + + --smf-type-tool-bg: #ecfeff; + --smf-type-tool-fg: #0e7490; + --smf-type-tool-border: #06b6d4; +} + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + --smf-safety-readonly-bg: #14532d; + --smf-safety-readonly-fg: #bbf7d0; + --smf-safety-readonly-border: #22c55e; + + --smf-safety-mutating-bg: #78350f; + --smf-safety-mutating-fg: #fde68a; + --smf-safety-mutating-border: #f59e0b; + + --smf-safety-destructive-bg: #7f1d1d; + --smf-safety-destructive-fg: #fecaca; + --smf-safety-destructive-border: #ef4444; + + --smf-type-tool-bg: #083344; + --smf-type-tool-fg: #67e8f9; + --smf-type-tool-border: #22d3ee; + } +} + +body[data-theme="dark"] { + --smf-safety-readonly-bg: #14532d; + --smf-safety-readonly-fg: #bbf7d0; + --smf-safety-readonly-border: #22c55e; + + --smf-safety-mutating-bg: #78350f; + --smf-safety-mutating-fg: #fde68a; + --smf-safety-mutating-border: #f59e0b; + + --smf-safety-destructive-bg: #7f1d1d; + --smf-safety-destructive-fg: #fecaca; + --smf-safety-destructive-border: #ef4444; + + --smf-type-tool-bg: #083344; + --smf-type-tool-fg: #67e8f9; + --smf-type-tool-border: #22d3ee; +} + +/* Hide ToC-only section title; card carries the visible header */ +.smf-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Signature row — match api-style flex */ +dl.mcp.tool > dt { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +dl.mcp.tool > dt .smf-toolbar { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; + margin-left: auto; + white-space: nowrap; + text-indent: 0; + order: 99; +} + +dl.mcp.tool > dt .smf-badge-group { + display: inline-flex; + align-items: center; + gap: 0.3rem; + white-space: nowrap; +} + +/* Badge base */ +.smf-badge { + position: relative; + display: inline-block; + font-size: var(--smf-badge-font-size, 0.67rem); + font-weight: 700; + line-height: normal; + letter-spacing: 0.01em; + padding: var(--smf-badge-padding-v, 0.16rem) 0.5rem; + border-radius: 0.22rem; + border: var(--smf-badge-border-w, 1px) solid; + vertical-align: middle; +} + +.smf-badge[tabindex]:focus::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + background: var(--color-background-primary); + border: 1px solid var(--color-background-border); + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 400; + white-space: nowrap; + border-radius: 0.2rem; + z-index: 10; + pointer-events: none; +} + +.smf-badge[tabindex]:focus-visible { + outline: 2px solid var(--color-link); + outline-offset: 2px; +} + +/* Safety tiers */ +abbr.smf-safety-readonly { + background-color: var(--smf-safety-readonly-bg); + color: var(--smf-safety-readonly-fg); + border-color: var(--smf-safety-readonly-border); +} + +abbr.smf-safety-mutating { + background-color: var(--smf-safety-mutating-bg); + color: var(--smf-safety-mutating-fg); + border-color: var(--smf-safety-mutating-border); +} + +abbr.smf-safety-destructive { + background-color: var(--smf-safety-destructive-bg); + color: var(--smf-safety-destructive-fg); + border-color: var(--smf-safety-destructive-border); +} + +abbr.smf-type-tool { + background-color: var(--smf-type-tool-bg); + color: var(--smf-type-tool-fg); + border-color: var(--smf-type-tool-border); +} + +abbr.smf-badge { + border-bottom-style: solid; + border-bottom-width: var(--smf-badge-border-w, 1px); + text-decoration: underline dotted; +} + +.smf-badge--icon-only { + min-width: 0.65rem; + padding-left: 0.35rem; + padding-right: 0.35rem; +} + +/* Card — aligned with sphinx-autodoc-api-style dl.py */ +dl.mcp.tool { + border: 1px solid var(--color-background-border); + border-radius: 0.5rem; + padding: 0; + margin-bottom: 1.5rem; + overflow: visible; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +dl.mcp.tool > dt { + background: var(--color-background-secondary); + border-bottom: 1px solid var(--color-background-border); + padding: 0.5rem 0.75rem; + text-indent: 0; + margin: 0; + padding-left: 1rem; + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + min-height: 2rem; + transition: background 100ms ease-out; +} + +dl.mcp.tool > dt:hover { + background: var(--color-api-background-hover); +} + +dl.mcp.tool > dd { + padding: 0.75rem 1rem; + margin-left: 0 !important; +} diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py new file mode 100644 index 0000000..011ca4b --- /dev/null +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -0,0 +1,160 @@ +"""Doctree hooks for labels, section badges, and tool reference resolution.""" + +from __future__ import annotations + +import logging +import re +import typing as t + +from docutils import nodes +from sphinx.application import Sphinx + +from sphinx_autodoc_fastmcp._badges import build_safety_badge +from sphinx_autodoc_fastmcp._models import ToolInfo +from sphinx_autodoc_fastmcp._roles import _tool_ref_placeholder + +if t.TYPE_CHECKING: + from sphinx.domains.std import StandardDomain + +logger = logging.getLogger(__name__) + + +def register_tool_labels(app: Sphinx, doctree: nodes.document) -> None: + """Mirror autosectionlabel for tool sections (``{ref}`tool-id```).""" + domain = t.cast("StandardDomain", app.env.get_domain("std")) + docname = app.env.docname + for section in doctree.findall(nodes.section): + if not section["ids"]: + continue + section_id = section["ids"][0] + if section.children and isinstance(section[0], nodes.title): + title_node = section[0] + tool_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + tool_name = child.astext() + break + if not tool_name: + continue + domain.anonlabels[section_id] = (docname, section_id) + domain.labels[section_id] = (docname, section_id, tool_name) + + +def add_section_badges( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Add safety badges to tier headings on configured pages.""" + pages: set[str] = set(app.config.fastmcp_section_badge_pages) + badge_map: dict[str, str] = dict(app.config.fastmcp_section_badge_map) + if fromdocname not in pages: + return + for section in doctree.findall(nodes.section): + if not section.children or not isinstance(section[0], nodes.title): + continue + title_text = section[0].astext().strip() + + safety = badge_map.get(title_text) + if safety is not None: + section[0] += nodes.Text(" ") + section[0] += build_safety_badge(safety) + continue + + m = re.match(r"^(\w+)\s*\((\w+)\)$", title_text) + if m: + heading, tier = m.group(1), m.group(2) + if heading in badge_map and tier == badge_map[heading]: + title_node = section[0] + title_node.clear() + title_node += nodes.Text(heading + " ") + title_node += build_safety_badge(tier) + + +def resolve_tool_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``:tool:`` / ``:toolref:`` / ``:toolicon*:`` placeholders.""" + domain = t.cast("StandardDomain", app.env.get_domain("std")) + builder = app.builder + tool_data: dict[str, ToolInfo] = getattr(app.env, "fastmcp_tools", {}) + + for node in list(doctree.findall(_tool_ref_placeholder)): + target = node.get("reftarget", "") + show_badge = node.get("show_badge", True) + icon_pos = node.get("icon_pos", "") + label_info = domain.labels.get(target) + if label_info is None: + node.replace_self(nodes.literal("", target.replace("-", "_"))) + continue + + todocname, labelid, _title = label_info + tool_name = target.replace("-", "_") + + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "sphinx_autodoc_fastmcp: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + + if icon_pos: + tool_info = tool_data.get(tool_name) + badge = None + if tool_info: + badge = build_safety_badge(tool_info.safety, icon_only=True) + if icon_pos.startswith("inline"): + badge["classes"].append("smf-badge--icon-only-inline") + + if icon_pos == "left": + if badge: + newnode += badge + newnode += nodes.literal("", tool_name) + elif icon_pos == "right": + newnode += nodes.literal("", tool_name) + if badge: + newnode += badge + elif icon_pos == "inline-left": + code_node = nodes.literal("", "") + if badge: + code_node += badge + code_node += nodes.Text(tool_name) + newnode += code_node + elif icon_pos == "inline-right": + code_node = nodes.literal("", "") + code_node += nodes.Text(tool_name) + if badge: + code_node += badge + newnode += code_node + else: + newnode += nodes.literal("", tool_name) + if show_badge: + tool_info = tool_data.get(tool_name) + if tool_info: + newnode += nodes.Text(" ") + newnode += build_safety_badge(tool_info.safety) + + node.replace_self(newnode) + + +def badge_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Role ``:badge:`readonly``` → safety badge.""" + return [build_safety_badge(text.strip())], [] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/py.typed b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py new file mode 100644 index 0000000..fecb68a --- /dev/null +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -0,0 +1,57 @@ +"""Tests for sphinx_autodoc_fastmcp.""" + +from __future__ import annotations + +from docutils import nodes + +from sphinx_autodoc_fastmcp._badges import build_safety_badge, build_tool_badge_group +from sphinx_autodoc_fastmcp._css import _CSS +from sphinx_autodoc_fastmcp._parsing import ( + first_paragraph, + make_table, + parse_numpy_params, +) +from sphinx_autodoc_fastmcp._roles import _tool_ref_placeholder + + +def test_css_prefix() -> None: + """CSS prefix is smf.""" + assert _CSS.PREFIX == "smf" + + +def test_badge_group_contains_tool_type() -> None: + """Tool badge group includes safety + type badge.""" + group = build_tool_badge_group("readonly") + assert _CSS.BADGE_GROUP in group["classes"] + abbrs = list(group.findall(nodes.abbreviation)) + assert len(abbrs) == 2 + assert "tool" in abbrs[-1].astext() + + +def test_safety_badge_abbreviation() -> None: + """Safety badge is an abbreviation node.""" + b = build_safety_badge("mutating") + assert isinstance(b, nodes.abbreviation) + assert b.astext() == "mutating" + + +def test_tool_placeholder_node() -> None: + """Placeholder stores hyphenated ref target.""" + n = _tool_ref_placeholder("", reftarget="list-sessions", show_badge=True) + assert n["reftarget"] == "list-sessions" + + +def test_parse_numpy_empty() -> None: + """Empty docstring yields no params.""" + assert parse_numpy_params("") == {} + + +def test_first_paragraph() -> None: + """First paragraph is extracted.""" + assert first_paragraph("a\n\nb") == "a" + + +def test_make_table_minimal() -> None: + """make_table builds a table node.""" + t = make_table(["A"], [["x"]]) + assert isinstance(t, nodes.table) From c0648229dea56d09f3ea6eb1261cce3c7650baed Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 14:18:02 -0500 Subject: [PATCH 02/14] chore(workspace): Wire sphinx-autodoc-fastmcp into uv workspace why: Register the new package for workspace resolution, dev deps, ruff, and pytest discovery; lockfile pairs with manifest per AGENTS.md. what: - tool.uv.sources and dev dependency for sphinx-autodoc-fastmcp - ruff first-party and per-file ignores; doctest testpaths - uv.lock --- pyproject.toml | 6 ++++++ uv.lock | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 18b891d..95e1dd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ sphinx-autodoc-pytest-fixtures = { workspace = true } sphinx-autodoc-docutils = { workspace = true } sphinx-autodoc-sphinx = { workspace = true } sphinx-autodoc-api-style = { workspace = true } +sphinx-autodoc-fastmcp = { workspace = true } gp-sphinx = { workspace = true } [dependency-groups] @@ -33,6 +34,7 @@ dev = [ "sphinx-autodoc-docutils", "sphinx-autodoc-sphinx", "sphinx-autodoc-api-style", + "sphinx-autodoc-fastmcp", # Docs "sphinx-autobuild", # Testing @@ -132,6 +134,7 @@ known-first-party = [ "sphinx_autodoc_docutils", "sphinx_autodoc_sphinx", "sphinx_autodoc_api_style", + "sphinx_autodoc_fastmcp", ] combine-as-imports = true required-imports = [ @@ -148,12 +151,14 @@ convention = "numpy" "packages/sphinx-autodoc-pytest-fixtures/**/*.py" = ["D417", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "packages/sphinx-autodoc-docutils/**/*.py" = ["D417", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "packages/sphinx-autodoc-api-style/**/*.py" = ["D417", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] +"packages/sphinx-autodoc-fastmcp/**/*.py" = ["D417", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/argparse_neo/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/autodoc_docutils/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/autodoc_sphinx/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/pytest_fixtures/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/api_style/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] +"tests/ext/fastmcp/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/docutils/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] [tool.pytest.ini_options] @@ -170,6 +175,7 @@ testpaths = [ "packages/sphinx-gptheme/src", "packages/sphinx-autodoc-sphinx/src", "packages/sphinx-autodoc-api-style/src", + "packages/sphinx-autodoc-fastmcp/src", ] filterwarnings = [ "ignore:distutils Version classes are deprecated. Use packaging.version instead.", diff --git a/uv.lock b/uv.lock index 7d41aaa..6feb2d1 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ members = [ "sphinx-argparse-neo", "sphinx-autodoc-api-style", "sphinx-autodoc-docutils", + "sphinx-autodoc-fastmcp", "sphinx-autodoc-pytest-fixtures", "sphinx-autodoc-sphinx", "sphinx-fonts", @@ -468,6 +469,7 @@ dev = [ { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-docutils" }, + { name = "sphinx-autodoc-fastmcp" }, { name = "sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -495,6 +497,7 @@ dev = [ { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", editable = "packages/sphinx-autodoc-api-style" }, { name = "sphinx-autodoc-docutils", editable = "packages/sphinx-autodoc-docutils" }, + { name = "sphinx-autodoc-fastmcp", editable = "packages/sphinx-autodoc-fastmcp" }, { name = "sphinx-autodoc-pytest-fixtures", editable = "packages/sphinx-autodoc-pytest-fixtures" }, { name = "sphinx-autodoc-sphinx", editable = "packages/sphinx-autodoc-sphinx" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -1289,6 +1292,18 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "sphinx" }] +[[package]] +name = "sphinx-autodoc-fastmcp" +version = "0.0.1a5" +source = { editable = "packages/sphinx-autodoc-fastmcp" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx" }] + [[package]] name = "sphinx-autodoc-pytest-fixtures" version = "0.0.1a5" From 36ebfb20478ad09a8ef0fcf2e9f4eaf41940b5f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 14:18:07 -0500 Subject: [PATCH 03/14] docs(packages): Add sphinx-autodoc-fastmcp package page and redirects why: Publish package reference in docs; legacy extensions/* redirects; CHANGES entry; doctest expectations for workspace discovery. what: - docs/packages/sphinx-autodoc-fastmcp.md and redirects.txt line - package_reference doctest set; test_package_reference expected names - CHANGES bullet for the new extension --- CHANGES | 2 ++ docs/_ext/package_reference.py | 1 + docs/packages/sphinx-autodoc-fastmcp.md | 25 +++++++++++++++++++++++++ docs/redirects.txt | 1 + tests/test_package_reference.py | 1 + 5 files changed, 30 insertions(+) create mode 100644 docs/packages/sphinx-autodoc-fastmcp.md diff --git a/CHANGES b/CHANGES index 5a42369..2fe1c01 100644 --- a/CHANGES +++ b/CHANGES @@ -20,6 +20,8 @@ $ uv add gp-sphinx --prerelease allow ### Features +- `sphinx-autodoc-fastmcp`: new Sphinx extension for FastMCP tool docs (card-style + `desc` layouts, safety badges, MyST directives, cross-reference roles) - Initial release of `gp_sphinx` shared documentation platform - `merge_sphinx_config()` API for building complete Sphinx config from shared defaults - Shared extension list, theme options, MyST config, font config diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 3be6664..05746b5 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -41,6 +41,7 @@ ... "sphinx-gptheme", ... "sphinx-argparse-neo", ... "sphinx-autodoc-docutils", +... "sphinx-autodoc-fastmcp", ... "sphinx-autodoc-pytest-fixtures", ... "sphinx-autodoc-sphinx", ... } diff --git a/docs/packages/sphinx-autodoc-fastmcp.md b/docs/packages/sphinx-autodoc-fastmcp.md new file mode 100644 index 0000000..64b0841 --- /dev/null +++ b/docs/packages/sphinx-autodoc-fastmcp.md @@ -0,0 +1,25 @@ +(sphinx-autodoc-fastmcp)= + +# sphinx-autodoc-fastmcp + +{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` + +Sphinx extension for documenting **FastMCP** tools: card-style `desc` layouts +(aligned with {doc}`sphinx-autodoc-api-style`), safety badges, parameter tables, +and cross-reference roles (`:tool:`, `:toolref:`, `:badge:`, etc.). + +```console +$ pip install sphinx-autodoc-fastmcp +``` + +## Features + +- **Tool cards**: `mcp` / `tool` domain `desc` nodes with toolbar badges +- **Collectors**: `register(mcp)`-style modules or `introspect` mode for `@mcp.tool` +- **Configuration**: module list, area map, model classes for type cross-refs +- **MyST directives**: `fastmcp-tool`, `fastmcp-tool-input`, `fastmcp-toolsummary` + +## Package reference + +```{package-reference} sphinx-autodoc-fastmcp +``` diff --git a/docs/redirects.txt b/docs/redirects.txt index f5f7e0a..4e92c2b 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -5,5 +5,6 @@ extensions/sphinx-autodoc-pytest-fixtures packages/sphinx-autodoc-pytest-fixture extensions/sphinx-autodoc-docutils packages/sphinx-autodoc-docutils extensions/sphinx-autodoc-sphinx packages/sphinx-autodoc-sphinx extensions/sphinx-autodoc-api-style packages/sphinx-autodoc-api-style +extensions/sphinx-autodoc-fastmcp packages/sphinx-autodoc-fastmcp extensions/sphinx-fonts packages/sphinx-fonts extensions/sphinx-gptheme packages/sphinx-gptheme diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index f3752d0..a35f45e 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -23,6 +23,7 @@ def test_workspace_packages_lists_publishable_packages() -> None: "sphinx-argparse-neo", "sphinx-autodoc-api-style", "sphinx-autodoc-docutils", + "sphinx-autodoc-fastmcp", "sphinx-autodoc-pytest-fixtures", "sphinx-autodoc-sphinx", "sphinx-fonts", From 45bfe952fb1d50d66d680d51dbab21d525115c3a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 16:30:15 -0500 Subject: [PATCH 04/14] autodoc-badges(feat): Add sphinx-autodoc-badges and migrate FastMCP/API-style/pytest why: Unified BadgeNode API for consistent badge styling across autodoc plugins; restore FastMCP tool sections and TOC sidebar compact badges. what: - Add sphinx-autodoc-badges package with BadgeNode, builders, shared CSS - Migrate sphinx-autodoc-fastmcp, api-style, pytest-fixtures to shared badges - Document new package; update redirects and package reference tests --- docs/packages/index.md | 4 +- docs/packages/sphinx-autodoc-badges.md | 24 ++ docs/redirects.txt | 1 + .../sphinx-autodoc-api-style/pyproject.toml | 1 + .../src/sphinx_autodoc_api_style/__init__.py | 15 +- .../src/sphinx_autodoc_api_style/_badges.py | 29 +- .../sphinx_autodoc_api_style/_transforms.py | 42 --- packages/sphinx-autodoc-badges/README.md | 6 + packages/sphinx-autodoc-badges/pyproject.toml | 40 +++ .../src/sphinx_autodoc_badges/__init__.py | 80 ++++++ .../src/sphinx_autodoc_badges/_builders.py | 139 ++++++++++ .../src/sphinx_autodoc_badges/_css.py | 40 +++ .../src/sphinx_autodoc_badges/_nodes.py | 64 +++++ .../_static/css/sphinx_autodoc_badges.css | 216 +++++++++++++++ .../src/sphinx_autodoc_badges/_visitors.py | 46 ++++ .../src/sphinx_autodoc_badges/py.typed | 0 .../sphinx-autodoc-fastmcp/pyproject.toml | 1 + .../src/sphinx_autodoc_fastmcp/__init__.py | 15 +- .../src/sphinx_autodoc_fastmcp/_badges.py | 122 ++++----- .../src/sphinx_autodoc_fastmcp/_css.py | 4 + .../src/sphinx_autodoc_fastmcp/_directives.py | 35 +-- .../_static/css/sphinx_autodoc_fastmcp.css | 258 +++++++----------- .../src/sphinx_autodoc_fastmcp/_transforms.py | 33 ++- .../pyproject.toml | 1 + .../__init__.py | 15 +- .../sphinx_autodoc_pytest_fixtures/_badges.py | 61 ++--- .../_transforms.py | 31 +-- pyproject.toml | 2 + tests/ext/api_style/test_api_style.py | 27 +- tests/ext/badges/__init__.py | 0 tests/ext/badges/test_badges.py | 152 +++++++++++ tests/ext/fastmcp/test_fastmcp.py | 30 +- .../test_sphinx_pytest_fixtures.py | 17 +- tests/test_package_reference.py | 1 + uv.lock | 27 +- 35 files changed, 1124 insertions(+), 455 deletions(-) create mode 100644 docs/packages/sphinx-autodoc-badges.md create mode 100644 packages/sphinx-autodoc-badges/README.md create mode 100644 packages/sphinx-autodoc-badges/pyproject.toml create mode 100644 packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/__init__.py create mode 100644 packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py create mode 100644 packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py create mode 100644 packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py create mode 100644 packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css create mode 100644 packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_visitors.py create mode 100644 packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/py.typed create mode 100644 tests/ext/badges/__init__.py create mode 100644 tests/ext/badges/test_badges.py diff --git a/docs/packages/index.md b/docs/packages/index.md index a896454..26a21a9 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,6 +1,6 @@ # Packages -Eight workspace packages, each independently installable. +Ten workspace packages, each independently installable. ```{workspace-package-grid} ``` @@ -10,7 +10,9 @@ Eight workspace packages, each independently installable. gp-sphinx sphinx-autodoc-api-style +sphinx-autodoc-badges sphinx-autodoc-docutils +sphinx-autodoc-fastmcp sphinx-autodoc-sphinx sphinx-autodoc-pytest-fixtures sphinx-fonts diff --git a/docs/packages/sphinx-autodoc-badges.md b/docs/packages/sphinx-autodoc-badges.md new file mode 100644 index 0000000..0f48a3f --- /dev/null +++ b/docs/packages/sphinx-autodoc-badges.md @@ -0,0 +1,24 @@ +# sphinx-autodoc-badges + +{bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` + +Shared badge node and CSS infrastructure for Sphinx autodoc extensions. + +Provides `BadgeNode`, HTML visitors, and builder helpers that +`sphinx-autodoc-api-style`, `sphinx-autodoc-pytest-fixtures`, and +`sphinx-autodoc-fastmcp` share instead of reimplementing badges independently. + +## Features + +- **`BadgeNode(nodes.inline)`** -- MRO-safe custom node that falls back to + `visit_inline` in text/LaTeX/man builders +- **Shared CSS** -- base metrics, icon-only, inline-icon, TOC dot compression, + context-aware sizing, heading flex alignment +- **CSS custom properties** -- plugins set `--sab-bg` / `--sab-fg` / `--sab-border`; + projects override in `custom.css` for palette variants +- **Builder API** -- `build_badge()`, `build_badge_group()`, `build_toolbar()` + +## Usage + +Extensions depend on this package and call `app.setup_extension("sphinx_autodoc_badges")` +in their `setup()` function. diff --git a/docs/redirects.txt b/docs/redirects.txt index 4e92c2b..62b2fdc 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -5,6 +5,7 @@ extensions/sphinx-autodoc-pytest-fixtures packages/sphinx-autodoc-pytest-fixture extensions/sphinx-autodoc-docutils packages/sphinx-autodoc-docutils extensions/sphinx-autodoc-sphinx packages/sphinx-autodoc-sphinx extensions/sphinx-autodoc-api-style packages/sphinx-autodoc-api-style +extensions/sphinx-autodoc-badges packages/sphinx-autodoc-badges extensions/sphinx-autodoc-fastmcp packages/sphinx-autodoc-fastmcp extensions/sphinx-fonts packages/sphinx-fonts extensions/sphinx-gptheme packages/sphinx-gptheme diff --git a/packages/sphinx-autodoc-api-style/pyproject.toml b/packages/sphinx-autodoc-api-style/pyproject.toml index fa9fd24..b8cc533 100644 --- a/packages/sphinx-autodoc-api-style/pyproject.toml +++ b/packages/sphinx-autodoc-api-style/pyproject.toml @@ -27,6 +27,7 @@ readme = "README.md" keywords = ["sphinx", "autodoc", "documentation", "api", "badges"] dependencies = [ "sphinx", + "sphinx-autodoc-badges==0.0.1a5", ] [project.urls] diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py index 1ae8ccf..8268143 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/__init__.py @@ -33,15 +33,9 @@ import pathlib import typing as t -from docutils import nodes - from sphinx_autodoc_api_style._badges import build_badge_group from sphinx_autodoc_api_style._css import _CSS -from sphinx_autodoc_api_style._transforms import ( - depart_abbreviation_html, - on_doctree_resolved, - visit_abbreviation_html, -) +from sphinx_autodoc_api_style._transforms import on_doctree_resolved if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -84,6 +78,7 @@ def setup(app: Sphinx) -> _SetupDict: True """ app.setup_extension("sphinx.ext.autodoc") + app.setup_extension("sphinx_autodoc_badges") _static_dir = str(pathlib.Path(__file__).parent / "_static") @@ -94,12 +89,6 @@ def _add_static_path(app: Sphinx) -> None: app.connect("builder-inited", _add_static_path) app.add_css_file("css/api_style.css") - app.add_node( - nodes.abbreviation, - override=True, - html=(visit_abbreviation_html, depart_abbreviation_html), - ) - app.connect("doctree-resolved", on_doctree_resolved) return { diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py index 613b58f..5cc1bed 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py @@ -1,8 +1,7 @@ """Badge group rendering helpers for sphinx_autodoc_api_style. -Builds portable ``nodes.abbreviation`` badge groups that render as -```` in HTML with hover tooltips and keyboard-accessible -focus states. +Uses shared ``BadgeNode`` from ``sphinx_autodoc_badges`` instead of +``nodes.abbreviation`` -- avoids global abbreviation visitor override. Examples -------- @@ -14,6 +13,7 @@ from __future__ import annotations from docutils import nodes +from sphinx_autodoc_badges import BadgeNode, build_badge from sphinx_autodoc_api_style._css import _CSS @@ -108,7 +108,7 @@ def build_badge_group( Returns ------- nodes.inline - Badge group container with abbreviation badge children. + Badge group container with BadgeNode children. Examples -------- @@ -117,29 +117,29 @@ def build_badge_group( True >>> group = build_badge_group("method", modifiers=frozenset({"async"})) - >>> len(list(group.findall(nodes.abbreviation))) == 2 + >>> len(list(group.findall(BadgeNode))) == 2 True >>> group = build_badge_group( ... "class", ... modifiers=frozenset({"abstract", "deprecated"}), ... ) - >>> labels = [n.astext() for n in group.findall(nodes.abbreviation)] + >>> labels = [n.astext() for n in group.findall(BadgeNode)] >>> "deprecated" in labels and "abstract" in labels and "class" in labels True """ group = nodes.inline(classes=[_CSS.BADGE_GROUP]) - badges: list[nodes.abbreviation] = [] + badges: list[BadgeNode] = [] for mod in _MOD_ORDER: if mod not in modifiers: continue badges.append( - nodes.abbreviation( + build_badge( _MOD_LABELS[mod], - _MOD_LABELS[mod], - explanation=_MOD_TOOLTIPS[mod], + tooltip=_MOD_TOOLTIPS[mod], classes=[_CSS.BADGE, _CSS.BADGE_MOD, _MOD_CSS[mod]], + fill="outline", ), ) @@ -147,17 +147,14 @@ def build_badge_group( label = _TYPE_LABELS.get(objtype, objtype) tooltip = _TYPE_TOOLTIPS.get(objtype, f"Python {objtype}") badges.append( - nodes.abbreviation( - label, + build_badge( label, - explanation=tooltip, + tooltip=tooltip, classes=[_CSS.BADGE, _CSS.BADGE_TYPE, _CSS.obj_type(objtype)], + fill="outline", ), ) - for badge in badges: - badge["tabindex"] = "0" - for i, badge in enumerate(badges): group += badge if i < len(badges) - 1: diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py index 23d0638..ec75272 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py @@ -12,7 +12,6 @@ from docutils import nodes from sphinx import addnodes from sphinx.util import logging as sphinx_logging -from sphinx.writers.html5 import HTML5Translator from sphinx_autodoc_api_style._badges import build_badge_group from sphinx_autodoc_api_style._css import _CSS @@ -231,44 +230,3 @@ def on_doctree_resolved( for child in desc_node.children: if isinstance(child, addnodes.desc_signature): _inject_badges(child, objtype) - - -def visit_abbreviation_html( - self: HTML5Translator, - node: nodes.abbreviation, -) -> None: - """Emit ```` with ``tabindex`` when present. - - Strict superset of Sphinx's built-in ``visit_abbreviation``: - non-badge abbreviation nodes produce identical output because the - ``tabindex`` guard only fires when the attribute is explicitly set. - - Parameters - ---------- - self : HTML5Translator - The HTML translator instance. - node : nodes.abbreviation - The abbreviation node being visited. - """ - attrs: dict[str, t.Any] = {} - if node.get("explanation"): - attrs["title"] = node["explanation"] - if node.get("tabindex"): - attrs["tabindex"] = node["tabindex"] - self.body.append(self.starttag(node, "abbr", "", **attrs)) - - -def depart_abbreviation_html( - self: HTML5Translator, - node: nodes.abbreviation, -) -> None: - """Close the ```` tag. - - Parameters - ---------- - self : HTML5Translator - The HTML translator instance. - node : nodes.abbreviation - The abbreviation node being departed. - """ - self.body.append("") diff --git a/packages/sphinx-autodoc-badges/README.md b/packages/sphinx-autodoc-badges/README.md new file mode 100644 index 0000000..9da4c4c --- /dev/null +++ b/packages/sphinx-autodoc-badges/README.md @@ -0,0 +1,6 @@ +# sphinx-autodoc-badges + +Shared badge node and CSS for Sphinx autodoc extensions in the gp-sphinx ecosystem. + +Provides `BadgeNode`, HTML visitors, and builder helpers that `sphinx-autodoc-api-style`, +`sphinx-autodoc-pytest-fixtures`, and `sphinx-autodoc-fastmcp` share. diff --git a/packages/sphinx-autodoc-badges/pyproject.toml b/packages/sphinx-autodoc-badges/pyproject.toml new file mode 100644 index 0000000..a2f5f75 --- /dev/null +++ b/packages/sphinx-autodoc-badges/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-autodoc-badges" +version = "0.0.1a5" +description = "Shared badge node and CSS for Sphinx autodoc extensions" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "badges", "documentation"] +dependencies = [ + "sphinx", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_autodoc_badges"] diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/__init__.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/__init__.py new file mode 100644 index 0000000..3d8f1f7 --- /dev/null +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/__init__.py @@ -0,0 +1,80 @@ +"""Shared badge infrastructure for Sphinx autodoc extensions. + +Provides :class:`BadgeNode`, HTML visitors, and builder helpers that all +``sphinx-autodoc-*`` packages share instead of reimplementing badges. + +Examples +-------- +>>> from sphinx_autodoc_badges import BadgeNode, build_badge +>>> callable(build_badge) +True + +>>> from sphinx_autodoc_badges import setup +>>> callable(setup) +True +""" + +from __future__ import annotations + +import logging +import pathlib +import typing as t + +from sphinx.application import Sphinx + +from sphinx_autodoc_badges._builders import ( + build_badge, + build_badge_group, + build_toolbar, +) +from sphinx_autodoc_badges._nodes import BadgeNode +from sphinx_autodoc_badges._visitors import depart_badge_html, visit_badge_html + +__all__ = [ + "BadgeNode", + "build_badge", + "build_badge_group", + "build_toolbar", + "setup", +] + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +_EXTENSION_VERSION = "0.0.1a5" + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register :class:`BadgeNode` with HTML visitor and shared CSS. + + Parameters + ---------- + app : Sphinx + Sphinx application. + + Returns + ------- + dict[str, Any] + Extension metadata. + + Examples + -------- + >>> from sphinx_autodoc_badges import setup + >>> callable(setup) + True + """ + app.add_node(BadgeNode, html=(visit_badge_html, depart_badge_html)) + + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/sphinx_autodoc_badges.css") + + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py new file mode 100644 index 0000000..bcf7917 --- /dev/null +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py @@ -0,0 +1,139 @@ +"""Badge builder helpers -- typed API for creating badge nodes. + +Examples +-------- +>>> b = build_badge("readonly", tooltip="Read-only", classes=["smf-safety-readonly"]) +>>> b.astext() +'readonly' + +>>> "sab-badge" in b["classes"] +True +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + +from sphinx_autodoc_badges._nodes import BadgeNode + + +def build_badge( + text: str, + *, + tooltip: str = "", + icon: str = "", + classes: t.Sequence[str] = (), + style: str = "full", + fill: str = "filled", + tabindex: str = "0", +) -> BadgeNode: + """Build a single badge node. + + Parameters + ---------- + text : str + Visible label. Empty string for icon-only badges. + tooltip : str + Hover text and ``aria-label``. + icon : str + Emoji character for CSS ``::before``. + classes : Sequence[str] + Additional CSS classes (plugin prefix + color class). + style : str + Structural variant: ``"full"``, ``"icon-only"``, ``"inline-icon"``. + fill : str + Visual fill: ``"filled"`` (default) or ``"outline"``. + tabindex : str + ``"0"`` for focusable, ``""`` to skip. + + Returns + ------- + BadgeNode + + Examples + -------- + >>> b = build_badge("async", tooltip="Asynchronous", classes=["gas-mod-async"]) + >>> b.astext() + 'async' + + >>> b = build_badge("", style="icon-only", classes=["smf-safety-readonly"]) + >>> "sab-icon-only" in b["classes"] + True + """ + extra_classes = list(classes) + if fill == "outline": + extra_classes.append("sab-outline") + return BadgeNode( + text, + badge_tooltip=tooltip, + badge_icon=icon, + badge_style=style, + tabindex=tabindex, + classes=extra_classes, + ) + + +def build_badge_group( + badges: t.Sequence[BadgeNode], + *, + classes: t.Sequence[str] = (), +) -> nodes.inline: + """Wrap badges in a group container with inter-badge spacing. + + Parameters + ---------- + badges : Sequence[BadgeNode] + Badge nodes to group. + classes : Sequence[str] + Additional CSS classes on the group container. + + Returns + ------- + nodes.inline + + Examples + -------- + >>> from sphinx_autodoc_badges._nodes import BadgeNode + >>> g = build_badge_group([BadgeNode("a"), BadgeNode("b")]) + >>> "sab-badge-group" in g["classes"] + True + """ + group = nodes.inline(classes=["sab-badge-group", *classes]) + for i, badge in enumerate(badges): + if i > 0: + group += nodes.Text(" ") + group += badge + return group + + +def build_toolbar( + badge_group: nodes.inline, + *, + classes: t.Sequence[str] = (), +) -> nodes.inline: + """Wrap a badge group in a toolbar (``margin-left: auto`` for flex titles). + + Parameters + ---------- + badge_group : nodes.inline + Badge group from :func:`build_badge_group`. + classes : Sequence[str] + Additional CSS classes on the toolbar. + + Returns + ------- + nodes.inline + + Examples + -------- + >>> from sphinx_autodoc_badges._nodes import BadgeNode + >>> g = build_badge_group([BadgeNode("x")]) + >>> t = build_toolbar(g, classes=["smf-toolbar"]) + >>> "sab-toolbar" in t["classes"] + True + """ + toolbar = nodes.inline(classes=["sab-toolbar", *classes]) + toolbar += badge_group + return toolbar diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py new file mode 100644 index 0000000..e38281b --- /dev/null +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py @@ -0,0 +1,40 @@ +"""Shared CSS class name constants for sphinx_autodoc_badges. + +Examples +-------- +>>> SAB.BADGE +'sab-badge' + +>>> SAB.BADGE_GROUP +'sab-badge-group' + +>>> SAB.TOOLBAR +'sab-toolbar' + +>>> SAB.ICON_ONLY +'sab-icon-only' +""" + +from __future__ import annotations + + +class SAB: + """CSS class constants (``sab-`` = sphinx autodoc badges). + + Examples + -------- + >>> SAB.PREFIX + 'sab' + + >>> SAB.OUTLINE + 'sab-outline' + """ + + PREFIX = "sab" + BADGE = f"{PREFIX}-badge" + BADGE_GROUP = f"{PREFIX}-badge-group" + TOOLBAR = f"{PREFIX}-toolbar" + ICON_ONLY = f"{PREFIX}-icon-only" + INLINE_ICON = f"{PREFIX}-inline-icon" + OUTLINE = f"{PREFIX}-outline" + FILLED = f"{PREFIX}-filled" diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py new file mode 100644 index 0000000..3b9e2ef --- /dev/null +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py @@ -0,0 +1,64 @@ +"""BadgeNode -- shared docutils node for inline badges. + +Subclasses ``nodes.inline`` so that unregistered builders (text, LaTeX, man) +fall back to ``visit_inline`` via Sphinx's MRO-based dispatch. + +Examples +-------- +>>> node = BadgeNode("readonly", badge_tooltip="Read-only operation") +>>> node.astext() +'readonly' + +>>> "sab-badge" in node["classes"] +True +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes + + +class BadgeNode(nodes.inline): + """Inline badge rendered as ```` with ARIA and icon support. + + Subclasses ``nodes.inline`` so unregistered builders (text, LaTeX, man) + fall back to ``visit_inline`` via MRO dispatch in + ``SphinxTranslator.dispatch_visit``. + + Examples + -------- + >>> b = BadgeNode("hello", badge_tooltip="greeting") + >>> b["badge_tooltip"] + 'greeting' + + >>> b.astext() + 'hello' + """ + + def __init__( + self, + text: str = "", + *, + badge_tooltip: str = "", + badge_icon: str = "", + badge_style: str = "full", + tabindex: str = "0", + classes: list[str] | None = None, + **attributes: t.Any, + ) -> None: + children = [nodes.Text(text)] if text else [] + super().__init__("", *children, **attributes) + self["classes"].append("sab-badge") + if classes: + self["classes"].extend(classes) + if badge_tooltip: + self["badge_tooltip"] = badge_tooltip + if badge_icon: + self["badge_icon"] = badge_icon + if badge_style != "full": + self["badge_style"] = badge_style + self["classes"].append(f"sab-{badge_style}") + if tabindex: + self["tabindex"] = tabindex diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css new file mode 100644 index 0000000..8a2f4b8 --- /dev/null +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css @@ -0,0 +1,216 @@ +/* sphinx_autodoc_badges — shared badge metrics, variants, and structural CSS. + * + * Base layer: metrics matching sphinx-design sd-badge (Bootstrap 5). + * Plugins add their own color classes via CSS custom properties + * (--sab-bg, --sab-fg, --sab-border). + */ + +/* ── Base badge ─────────────────────────────────────────── */ +.sab-badge { + display: inline-flex; + align-items: center; + gap: var(--sab-icon-gap, 0.28rem); + font-size: var(--sab-font-size, 0.75em); + font-weight: var(--sab-font-weight, 700); + line-height: 1; + letter-spacing: 0.01em; + padding: var(--sab-padding-v, 0.35em) var(--sab-padding-h, 0.65em); + border-radius: var(--sab-radius, 0.25rem); + border: var(--sab-border, none); + background: var(--sab-bg); + color: var(--sab-fg); + vertical-align: middle; + white-space: nowrap; + text-align: center; + text-decoration: none; + user-select: none; + -webkit-user-select: none; + box-sizing: border-box; +} + +.sab-badge:focus-visible { + outline: 2px solid var(--color-link, #2962ff); + outline-offset: 2px; +} + +/* ── Outline fill variant ───────────────────────────────── */ +.sab-badge.sab-outline { + background: transparent; +} + +/* ── Icon system (::before pseudo-element) ──────────────── */ +.sab-badge::before { + font-style: normal; + font-weight: normal; + font-size: 1em; + line-height: 1; + flex-shrink: 0; +} + +.sab-badge[data-icon]::before { + content: attr(data-icon); +} + +/* ── Badge group ────────────────────────────────────────── */ +.sab-badge-group { + display: inline-flex; + align-items: center; + gap: 0.3rem; + white-space: nowrap; +} + +/* ── Toolbar (flex margin-left: auto for title rows) ────── */ +.sab-toolbar { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; + margin-left: auto; + white-space: nowrap; + text-indent: 0; + order: 99; +} + +/* ── Icon-only variant (outside code: 16x16 colored box) ── */ +.sab-badge.sab-icon-only { + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + box-sizing: border-box; + border-radius: 3px; + gap: 0; + font-size: 0; + line-height: 1; + min-width: 0; + min-height: 0; + margin: 0; +} + +.sab-badge.sab-icon-only::before { + font-size: 10px; + line-height: 1; + font-style: normal; + font-weight: normal; + margin: 0; + display: block; + opacity: 0.9; +} + +/* Icon-only links: flexbox parent for consistent spacing */ +a.reference:has(> .sab-badge.sab-icon-only) { + display: inline-flex; + align-items: center; + gap: 3px; +} + +a.reference:has(> .sab-badge.sab-icon-only) > code { + margin: 0; +} + +/* ── Inline-icon variant (bare emoji inside code chip) ──── */ +.sab-badge.sab-inline-icon { + background: transparent !important; + border: none !important; + padding: 0; + width: auto; + height: auto; + border-radius: 0; + vertical-align: -0.01em; + margin-right: 0.12em; + margin-left: 0; +} + +.sab-badge.sab-inline-icon::before { + font-size: 0.78rem; + opacity: 0.85; +} + +code.docutils .sab-badge.sab-inline-icon:last-child { + margin-left: 0.1em; + margin-right: 0; +} + +/* ── Context-aware badge sizing ─────────────────────────── * + * Scoped to .document-content (Furo main body) to avoid + * applying in sidebar, TOC, or navigation contexts. + */ +.body h2 .sab-badge, +.body h3 .sab-badge, +[role="main"] h2 .sab-badge, +[role="main"] h3 .sab-badge { + font-size: 0.68rem; + padding: 0.17rem 0.4rem; +} + +.body p .sab-badge, +.body td .sab-badge, +[role="main"] p .sab-badge, +[role="main"] td .sab-badge { + font-size: 0.62rem; + padding: 0.12rem 0.32rem; +} + +/* ── Consistent code → badge spacing (body only) ────────── */ +.body code.docutils + .sab-badge, +.body .sab-badge + code.docutils, +[role="main"] code.docutils + .sab-badge, +[role="main"] .sab-badge + code.docutils { + margin-left: 0.4em; +} + +/* ── Link behavior: underline code only, on hover ───────── */ +.body a.reference .sab-badge, +[role="main"] a.reference .sab-badge { + text-decoration: none; + vertical-align: middle; +} + +.body a.reference:has(.sab-badge) code, +[role="main"] a.reference:has(.sab-badge) code { + vertical-align: middle; + text-decoration: none; +} + +.body a.reference:has(.sab-badge), +[role="main"] a.reference:has(.sab-badge) { + text-decoration: none; +} + +.body a.reference:has(.sab-badge):hover code, +[role="main"] a.reference:has(.sab-badge):hover code { + text-decoration: underline; +} + +/* ── TOC sidebar: compact badges ───────────────────────── + * Smaller badges that still show text (matching production). + * Container wrappers collapse to inline flow. + * Emoji icons hidden at this size. + */ +.toc-tree .sab-toolbar, +.toc-tree .sab-badge-group { + display: inline; + gap: 0; + margin: 0; + padding: 0; + border: none; + background: none; +} + +.toc-tree .sab-badge { + font-size: 0.55rem; + padding: 0.08rem 0.22rem; + gap: 0; + vertical-align: middle; + line-height: 1.1; +} + +.toc-tree .sab-badge::before { + display: none; +} + +.toc-tree .sab-badge[data-icon]::before { + display: none; +} diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_visitors.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_visitors.py new file mode 100644 index 0000000..bca7c98 --- /dev/null +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_visitors.py @@ -0,0 +1,46 @@ +"""HTML5 visitors for BadgeNode.""" + +from __future__ import annotations + +import typing as t + +from sphinx.writers.html5 import HTML5Translator + +if t.TYPE_CHECKING: + from sphinx_autodoc_badges._nodes import BadgeNode + + +def visit_badge_html(self: HTML5Translator, node: BadgeNode) -> None: + """Emit opening ```` with ARIA, tooltip, icon data attribute. + + Uses ``self.starttag()`` which auto-emits ``class="..."`` from + ``node["classes"]``. + + Examples + -------- + >>> from sphinx_autodoc_badges._nodes import BadgeNode + >>> b = BadgeNode("ok", badge_tooltip="tip") + >>> b["badge_tooltip"] + 'tip' + """ + attrs: dict[str, str] = {} + + tooltip = node.get("badge_tooltip", "") + if tooltip: + attrs["title"] = tooltip + attrs["aria-label"] = tooltip + + icon = node.get("badge_icon", "") + if icon: + attrs["data-icon"] = icon + + tabindex = node.get("tabindex", "") + if tabindex: + attrs["tabindex"] = tabindex + + self.body.append(self.starttag(node, "span", "", role="note", **attrs)) # type: ignore[arg-type] + + +def depart_badge_html(self: HTML5Translator, node: BadgeNode) -> None: + """Close the ````.""" + self.body.append("") diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/py.typed b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/sphinx-autodoc-fastmcp/pyproject.toml b/packages/sphinx-autodoc-fastmcp/pyproject.toml index 6ac619d..66c22a5 100644 --- a/packages/sphinx-autodoc-fastmcp/pyproject.toml +++ b/packages/sphinx-autodoc-fastmcp/pyproject.toml @@ -27,6 +27,7 @@ readme = "README.md" keywords = ["sphinx", "fastmcp", "mcp", "documentation", "badges"] dependencies = [ "sphinx", + "sphinx-autodoc-badges==0.0.1a5", ] [project.urls] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 3575d1f..f7e61b7 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -13,13 +13,8 @@ import pathlib import typing as t -from docutils import nodes from sphinx.application import Sphinx -from sphinx_autodoc_fastmcp._badges import ( - depart_abbreviation_html, - visit_abbreviation_html, -) from sphinx_autodoc_fastmcp._collector import collect_tools from sphinx_autodoc_fastmcp._directives import ( FastMCPToolDirective, @@ -38,6 +33,7 @@ from sphinx_autodoc_fastmcp._transforms import ( add_section_badges, badge_role, + collect_tool_section_content, register_tool_labels, resolve_tool_refs, ) @@ -70,6 +66,8 @@ def setup(app: Sphinx) -> dict[str, t.Any]: >>> callable(setup) True """ + app.setup_extension("sphinx_autodoc_badges") + app.add_config_value("fastmcp_tool_modules", [], "env") app.add_config_value("fastmcp_area_map", {}, "env") app.add_config_value("fastmcp_model_module", "", "env") @@ -87,14 +85,9 @@ def _add_static_path(app: Sphinx) -> None: app.connect("builder-inited", _add_static_path) app.add_css_file("css/sphinx_autodoc_fastmcp.css") - app.add_node( - nodes.abbreviation, - override=True, - html=(visit_abbreviation_html, depart_abbreviation_html), - ) - app.connect("builder-inited", collect_tools) app.connect("doctree-read", register_tool_labels) + app.connect("doctree-read", collect_tool_section_content) app.connect("doctree-resolved", add_section_badges) app.connect("doctree-resolved", resolve_tool_refs) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py index bf272b2..5a8f1a7 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_badges.py @@ -1,20 +1,29 @@ -"""Badge nodes and HTML visitors for sphinx_autodoc_fastmcp.""" +"""Badge helpers for sphinx_autodoc_fastmcp (thin wrappers over shared API).""" from __future__ import annotations -import typing as t - from docutils import nodes -from sphinx.writers.html5 import HTML5Translator +from sphinx_autodoc_badges import ( + BadgeNode, + build_badge, + build_badge_group, + build_toolbar as _sab_build_toolbar, +) from sphinx_autodoc_fastmcp._css import _CSS _SAFETY_LABELS = ("readonly", "mutating", "destructive") _SAFETY_TOOLTIPS: dict[str, str] = { - "readonly": "Read-only — does not modify external state", - "mutating": "Mutating — creates or modifies objects", - "destructive": "Destructive — may remove data; not reversible", + "readonly": "Read-only \u2014 does not modify external state", + "mutating": "Mutating \u2014 creates or modifies objects", + "destructive": "Destructive \u2014 may remove data; not reversible", +} + +_SAFETY_ICONS: dict[str, str] = { + "readonly": "\U0001f50d", + "mutating": "\u270f\ufe0f", + "destructive": "\U0001f4a3", } _TYPE_TOOLTIP = "MCP tool" @@ -24,19 +33,19 @@ def build_safety_badge( safety: str, *, icon_only: bool = False, -) -> nodes.abbreviation: - """Build a single safety tier badge as an ``abbreviation`` node. +) -> BadgeNode: + """Build a safety tier badge. Parameters ---------- safety : str One of ``readonly``, ``mutating``, ``destructive``. icon_only : bool - When True, use a narrow non-breaking space for icon-only layouts. + When True, create an icon-only badge (empty text, 16x16 colored box). Returns ------- - nodes.abbreviation + BadgeNode Examples -------- @@ -45,36 +54,36 @@ def build_safety_badge( 'readonly' """ label = safety if safety in _SAFETY_LABELS else safety - text = "\u00a0" if icon_only else label - classes = [_CSS.BADGE, _CSS.BADGE_SAFETY] - if safety in _SAFETY_LABELS: - classes.append(_CSS.safety_class(safety)) - if icon_only: - classes.append(f"{_CSS.PREFIX}-badge--icon-only") - abbr = nodes.abbreviation( + text = "" if icon_only else label + style = "icon-only" if icon_only else "full" + classes = [_CSS.BADGE_SAFETY, _CSS.safety_class(safety)] + return build_badge( text, - text, - explanation=_SAFETY_TOOLTIPS.get(safety, f"Safety: {safety}"), + tooltip=_SAFETY_TOOLTIPS.get(safety, f"Safety: {safety}"), + icon=_SAFETY_ICONS.get(safety, ""), classes=classes, + style=style, ) - abbr["tabindex"] = "0" - return abbr -def build_type_tool_badge() -> nodes.abbreviation: - """Rightmost type badge labeling the entry as an MCP tool.""" - abbr = nodes.abbreviation( - "tool", +def build_type_tool_badge() -> BadgeNode: + """Rightmost type badge labeling the entry as an MCP tool. + + Examples + -------- + >>> b = build_type_tool_badge() + >>> b.astext() + 'tool' + """ + return build_badge( "tool", - explanation=_TYPE_TOOLTIP, - classes=[_CSS.BADGE, _CSS.BADGE_TYPE, _CSS.TYPE_TOOL], + tooltip=_TYPE_TOOLTIP, + classes=[_CSS.BADGE_TYPE, _CSS.TYPE_TOOL], ) - abbr["tabindex"] = "0" - return abbr def build_tool_badge_group(safety: str) -> nodes.inline: - """Badge group for a tool signature: safety tier + type ``tool``. + """Badge group: safety tier + type ``tool``. Parameters ---------- @@ -84,46 +93,29 @@ def build_tool_badge_group(safety: str) -> nodes.inline: Returns ------- nodes.inline - Container with class ``smf-badge-group``. Examples -------- >>> g = build_tool_badge_group("readonly") - >>> _CSS.BADGE_GROUP in g["classes"] + >>> "sab-badge-group" in g["classes"] True """ - group = nodes.inline(classes=[_CSS.BADGE_GROUP]) - safety_badge = build_safety_badge(safety) - type_badge = build_type_tool_badge() - group += safety_badge - group += nodes.Text(" ") - group += type_badge - return group + return build_badge_group( + [build_safety_badge(safety), build_type_tool_badge()], + classes=[_CSS.BADGE_GROUP], + ) def build_toolbar(safety: str) -> nodes.inline: - """Toolbar container (signature right side): badge group only.""" - toolbar = nodes.inline(classes=[_CSS.TOOLBAR]) - toolbar += build_tool_badge_group(safety) - return toolbar - - -def visit_abbreviation_html( - self: HTML5Translator, - node: nodes.abbreviation, -) -> None: - """Emit ```` with ``tabindex`` when present (keyboard tooltips).""" - attrs: dict[str, t.Any] = {} - if node.get("explanation"): - attrs["title"] = node["explanation"] - if node.get("tabindex"): - attrs["tabindex"] = node["tabindex"] - self.body.append(self.starttag(node, "abbr", "", **attrs)) - - -def depart_abbreviation_html( - self: HTML5Translator, - node: nodes.abbreviation, -) -> None: - """Close the ```` tag.""" - self.body.append("") + """Toolbar on the title row (flex ``margin-left: auto``). + + Examples + -------- + >>> t = build_toolbar("readonly") + >>> "sab-toolbar" in t["classes"] + True + """ + return _sab_build_toolbar( + build_tool_badge_group(safety), + classes=[_CSS.TOOLBAR], + ) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py index a88ce45..20df881 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_css.py @@ -10,6 +10,9 @@ >>> _CSS.TOOLBAR 'smf-toolbar' + +>>> _CSS.TOOL_SECTION +'smf-tool-section' """ from __future__ import annotations @@ -19,6 +22,7 @@ class _CSS: """CSS class name constants (``smf-`` = sphinx autodoc fastmcp).""" PREFIX = "smf" + TOOL_SECTION = f"{PREFIX}-tool-section" BADGE_GROUP = f"{PREFIX}-badge-group" BADGE = f"{PREFIX}-badge" BADGE_TYPE = f"{PREFIX}-badge--type" diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py index eba8573..811137b 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_directives.py @@ -3,7 +3,6 @@ from __future__ import annotations from docutils import nodes -from sphinx import addnodes from sphinx.util.docutils import SphinxDirective from sphinx_autodoc_fastmcp._badges import build_toolbar @@ -21,7 +20,7 @@ class FastMCPToolDirective(SphinxDirective): - """Autodocument one MCP tool: section (ToC/labels) + ``desc`` card.""" + """Autodocument one MCP tool: section (ToC/labels) + card body.""" required_arguments = 1 optional_arguments = 0 @@ -29,7 +28,7 @@ class FastMCPToolDirective(SphinxDirective): final_argument_whitespace = False def run(self) -> list[nodes.Node]: - """Build section + ``mcp`` ``desc`` nodes for one tool.""" + """Build section with title row + docstring/returns for one tool.""" arg = self.arguments[0] func_name = arg.split(".")[-1] if "." in arg else arg @@ -48,36 +47,24 @@ def run(self) -> list[nodes.Node]: return self._build_tool_section(tool) def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: - """Build visually hidden section title + card ``desc``.""" + """Build section card: title (literal + badges) + summary + returns.""" document = self.state.document section_id = tool.name.replace("_", "-") section = nodes.section() section["ids"].append(section_id) + section["classes"].append(_CSS.TOOL_SECTION) document.note_explicit_target(section) title_node = nodes.title("", "") + title_node["classes"].append(f"{_CSS.PREFIX}-tool-title") title_node += nodes.literal("", tool.name) - title_node["classes"].append(_CSS.SECTION_TITLE_HIDDEN) + title_node += nodes.Text(" ") + title_node += build_toolbar(tool.safety) section += title_node - desc_node = addnodes.desc() - desc_node["domain"] = "mcp" - desc_node["objtype"] = "tool" - desc_node["desctype"] = "tool" - desc_node["classes"].extend(["mcp", "tool"]) - self.set_source_info(desc_node) - - signode = addnodes.desc_signature("", "") - self.set_source_info(signode) - signode += addnodes.desc_name("", tool.name) - signode += build_toolbar(tool.safety) - - content_node = addnodes.desc_content("") - self.set_source_info(content_node) - first_para = first_paragraph(tool.docstring) - content_node += parse_rst_inline(first_para, self.state, self.lineno) + section += parse_rst_inline(first_para, self.state, self.lineno) if tool.return_annotation: returns_para = nodes.paragraph("") @@ -89,11 +76,7 @@ def _build_tool_section(self, tool: ToolInfo) -> list[nodes.Node]: ) for child in type_para.children: returns_para += child.deepcopy() - content_node += returns_para - - desc_node += signode - desc_node += content_node - section += desc_node + section += returns_para return [section] diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css index 53cbba2..6d4b925 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -1,205 +1,135 @@ -/* sphinx_autodoc_fastmcp — FastMCP tool cards (dl.mcp.tool) + smf badges */ +/* sphinx_autodoc_fastmcp — color layer for FastMCP tool badges. + * + * Base metrics, icon-only, TOC dots, context sizing, heading flex + * come from sphinx_autodoc_badges.css (shared package). + * This file provides colors, tool section card styling, and emoji icons. + */ :root { - --smf-badge-font-size: 0.67rem; - --smf-badge-padding-v: 0.16rem; - --smf-badge-border-w: 1px; - - --smf-safety-readonly-bg: #dcfce7; - --smf-safety-readonly-fg: #166534; - --smf-safety-readonly-border: #22c55e; - - --smf-safety-mutating-bg: #fef3c7; - --smf-safety-mutating-fg: #92400e; - --smf-safety-mutating-border: #f59e0b; - - --smf-safety-destructive-bg: #fee2e2; - --smf-safety-destructive-fg: #991b1b; - --smf-safety-destructive-border: #ef4444; - - --smf-type-tool-bg: #ecfeff; - --smf-type-tool-fg: #0e7490; - --smf-type-tool-border: #06b6d4; + --smf-readonly-bg: #28a745; + --smf-readonly-fg: #fff; + --smf-mutating-bg: #f0b37e; + --smf-mutating-fg: #1a1a1a; + --smf-destructive-bg: #dc3545; + --smf-destructive-fg: #fff; + --smf-type-tool-bg: #0e7490; + --smf-type-tool-fg: #fff; } @media (prefers-color-scheme: dark) { body:not([data-theme="light"]) { - --smf-safety-readonly-bg: #14532d; - --smf-safety-readonly-fg: #bbf7d0; - --smf-safety-readonly-border: #22c55e; - - --smf-safety-mutating-bg: #78350f; - --smf-safety-mutating-fg: #fde68a; - --smf-safety-mutating-border: #f59e0b; - - --smf-safety-destructive-bg: #7f1d1d; - --smf-safety-destructive-fg: #fecaca; - --smf-safety-destructive-border: #ef4444; - - --smf-type-tool-bg: #083344; - --smf-type-tool-fg: #67e8f9; - --smf-type-tool-border: #22d3ee; + --smf-readonly-bg: #2ea043; + --smf-mutating-bg: #d4a574; + --smf-destructive-bg: #f85149; + --smf-type-tool-bg: #22d3ee; + --smf-type-tool-fg: #0f172a; } } body[data-theme="dark"] { - --smf-safety-readonly-bg: #14532d; - --smf-safety-readonly-fg: #bbf7d0; - --smf-safety-readonly-border: #22c55e; - - --smf-safety-mutating-bg: #78350f; - --smf-safety-mutating-fg: #fde68a; - --smf-safety-mutating-border: #f59e0b; - - --smf-safety-destructive-bg: #7f1d1d; - --smf-safety-destructive-fg: #fecaca; - --smf-safety-destructive-border: #ef4444; - - --smf-type-tool-bg: #083344; - --smf-type-tool-fg: #67e8f9; - --smf-type-tool-border: #22d3ee; + --smf-readonly-bg: #2ea043; + --smf-mutating-bg: #d4a574; + --smf-destructive-bg: #f85149; + --smf-type-tool-bg: #22d3ee; + --smf-type-tool-fg: #0f172a; } -/* Hide ToC-only section title; card carries the visible header */ -.smf-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; +/* ── Safety tier colors ─────────────────────────────────── */ +.smf-safety-readonly { + --sab-bg: var(--smf-readonly-bg); + --sab-fg: var(--smf-readonly-fg); } -/* Signature row — match api-style flex */ -dl.mcp.tool > dt { - display: flex; - align-items: center; - gap: 0.35rem; - flex-wrap: wrap; +.smf-safety-mutating { + --sab-bg: var(--smf-mutating-bg); + --sab-fg: var(--smf-mutating-fg); } -dl.mcp.tool > dt .smf-toolbar { - display: inline-flex; - align-items: center; - gap: 0.35rem; - flex-shrink: 0; - margin-left: auto; - white-space: nowrap; - text-indent: 0; - order: 99; +.smf-safety-destructive { + --sab-bg: var(--smf-destructive-bg); + --sab-fg: var(--smf-destructive-fg); } -dl.mcp.tool > dt .smf-badge-group { - display: inline-flex; - align-items: center; - gap: 0.3rem; - white-space: nowrap; +.smf-type-tool { + --sab-bg: var(--smf-type-tool-bg); + --sab-fg: var(--smf-type-tool-fg); } -/* Badge base */ -.smf-badge { - position: relative; - display: inline-block; - font-size: var(--smf-badge-font-size, 0.67rem); - font-weight: 700; - line-height: normal; - letter-spacing: 0.01em; - padding: var(--smf-badge-padding-v, 0.16rem) 0.5rem; - border-radius: 0.22rem; - border: var(--smf-badge-border-w, 1px) solid; - vertical-align: middle; -} +/* ── Emoji icons via ::before ───────────────────────────── */ +.smf-safety-readonly::before { content: "\1F50D"; } +.smf-safety-mutating::before { content: "\270F\FE0F"; } +.smf-safety-destructive::before { content: "\1F4A3"; } -.smf-badge[tabindex]:focus::after { - content: attr(title); - position: absolute; - bottom: calc(100% + 4px); - left: 50%; - transform: translateX(-50%); - background: var(--color-background-primary); +/* ── Tool section card ──────────────────────────────────── */ +section.smf-tool-section { border: 1px solid var(--color-background-border); - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 400; - white-space: nowrap; - border-radius: 0.2rem; - z-index: 10; - pointer-events: none; -} - -.smf-badge[tabindex]:focus-visible { - outline: 2px solid var(--color-link); - outline-offset: 2px; -} - -/* Safety tiers */ -abbr.smf-safety-readonly { - background-color: var(--smf-safety-readonly-bg); - color: var(--smf-safety-readonly-fg); - border-color: var(--smf-safety-readonly-border); + border-radius: 0.5rem; + padding: 0; + margin-bottom: 1.5rem; + overflow: visible; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); } -abbr.smf-safety-mutating { - background-color: var(--smf-safety-mutating-bg); - color: var(--smf-safety-mutating-fg); - border-color: var(--smf-safety-mutating-border); +section.smf-tool-section > h1, +section.smf-tool-section > h2, +section.smf-tool-section > h3, +section.smf-tool-section > h4, +section.smf-tool-section > h5, +section.smf-tool-section > h6 { + margin: 0; } -abbr.smf-safety-destructive { - background-color: var(--smf-safety-destructive-bg); - color: var(--smf-safety-destructive-fg); - border-color: var(--smf-safety-destructive-border); +section.smf-tool-section > .smf-tool-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem; + background: var(--color-background-secondary); + border-bottom: 1px solid var(--color-background-border); + padding: 0.5rem 0.75rem 0.5rem 1rem; + min-height: 2rem; + text-indent: 0; + transition: background 100ms ease-out; } -abbr.smf-type-tool { - background-color: var(--smf-type-tool-bg); - color: var(--smf-type-tool-fg); - border-color: var(--smf-type-tool-border); +section.smf-tool-section > .smf-tool-title:hover { + background: var(--color-api-background-hover); } -abbr.smf-badge { - border-bottom-style: solid; - border-bottom-width: var(--smf-badge-border-w, 1px); - text-decoration: underline dotted; +section.smf-tool-section > .smf-tool-title .sab-toolbar { + order: 99; } -.smf-badge--icon-only { - min-width: 0.65rem; - padding-left: 0.35rem; - padding-right: 0.35rem; +/* Body: docstring, returns, MyST blocks, fastmcp-tool-input tables */ +section.smf-tool-section > .smf-tool-title ~ * { + padding-left: 1rem; + padding-right: 1rem; + margin-left: 0; + margin-right: 0; } -/* Card — aligned with sphinx-autodoc-api-style dl.py */ -dl.mcp.tool { - border: 1px solid var(--color-background-border); - border-radius: 0.5rem; - padding: 0; - margin-bottom: 1.5rem; - overflow: visible; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +section.smf-tool-section > .smf-tool-title + * { + padding-top: 0.75rem; } -dl.mcp.tool > dt { - background: var(--color-background-secondary); - border-bottom: 1px solid var(--color-background-border); - padding: 0.5rem 0.75rem; - text-indent: 0; - margin: 0; - padding-left: 1rem; - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - min-height: 2rem; - transition: background 100ms ease-out; +section.smf-tool-section > .smf-tool-title ~ *:last-child { + padding-bottom: 0.75rem; } -dl.mcp.tool > dt:hover { - background: var(--color-api-background-hover); +/* ── TOC sidebar: hide type badge, keep only safety ─────── */ +.toc-tree .smf-badge--type { + display: none !important; } -dl.mcp.tool > dd { - padding: 0.75rem 1rem; - margin-left: 0 !important; +/* ── Visually hidden title (for ToC anchors) ────────────── */ +.smf-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py index 011ca4b..f7ab684 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_transforms.py @@ -10,6 +10,7 @@ from sphinx.application import Sphinx from sphinx_autodoc_fastmcp._badges import build_safety_badge +from sphinx_autodoc_fastmcp._css import _CSS from sphinx_autodoc_fastmcp._models import ToolInfo from sphinx_autodoc_fastmcp._roles import _tool_ref_placeholder @@ -19,6 +20,33 @@ logger = logging.getLogger(__name__) +def collect_tool_section_content(app: Sphinx, doctree: nodes.document) -> None: + """Move siblings following each tool section into the section. + + Directive-returned ``nodes.section`` is a closed node — MyST does not + "enter" it. This transform runs after parsing and re-parents prose, + code blocks, and ``{fastmcp-tool-input}`` tables that sit between one + tool section and the next boundary (``---`` transition or another tool + section). + """ + for section in list(doctree.findall(nodes.section)): + if _CSS.TOOL_SECTION not in section.get("classes", []): + continue + parent = section.parent + if parent is None: + continue + idx = parent.index(section) + while idx + 1 < len(parent.children): + sibling = parent.children[idx + 1] + if isinstance(sibling, nodes.transition): + parent.remove(sibling) + break + if isinstance(sibling, nodes.section): + break + parent.remove(sibling) + section.append(sibling) + + def register_tool_labels(app: Sphinx, doctree: nodes.document) -> None: """Mirror autosectionlabel for tool sections (``{ref}`tool-id```).""" domain = t.cast("StandardDomain", app.env.get_domain("std")) @@ -112,9 +140,10 @@ def resolve_tool_refs( tool_info = tool_data.get(tool_name) badge = None if tool_info: + style = "inline-icon" if icon_pos.startswith("inline") else "icon-only" badge = build_safety_badge(tool_info.safety, icon_only=True) - if icon_pos.startswith("inline"): - badge["classes"].append("smf-badge--icon-only-inline") + if style == "inline-icon": + badge["classes"].append("sab-inline-icon") if icon_pos == "left": if badge: diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml index 5517f0e..4eeb86e 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -30,6 +30,7 @@ keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] dependencies = [ "sphinx", "pytest", + "sphinx-autodoc-badges==0.0.1a5", ] [project.urls] diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py index e73eb7f..8dc868a 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py @@ -75,10 +75,8 @@ _on_env_updated, ) from sphinx_autodoc_pytest_fixtures._transforms import ( - _depart_abbreviation_html, _on_doctree_resolved, _on_missing_reference, - _visit_abbreviation_html, ) if t.TYPE_CHECKING: @@ -101,9 +99,8 @@ def setup(app: Sphinx) -> SetupDict: Extension metadata dict. """ app.setup_extension("sphinx.ext.autodoc") + app.setup_extension("sphinx_autodoc_badges") - # Register extension CSS so projects adopting this extension get styled - # output without manually copying spf-* rules into their custom.css. import pathlib _static_dir = str(pathlib.Path(__file__).parent / "_static") @@ -115,16 +112,6 @@ def _add_static_path(app: Sphinx) -> None: app.connect("builder-inited", _add_static_path) app.add_css_file("css/sphinx_autodoc_pytest_fixtures.css") - # Override the built-in abbreviation visitor to emit tabindex when set. - # Sphinx's default visit_abbreviation only passes explanation → title, - # silently dropping all other attributes. This override is a strict - # superset — non-badge abbreviation nodes produce identical output. - app.add_node( - nodes.abbreviation, - override=True, - html=(_visit_abbreviation_html, _depart_abbreviation_html), - ) - # --- New config values (v1.1) --- app.add_config_value( _CONFIG_HIDDEN_DEPS, diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py index a3f6143..8c94bb6 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py @@ -3,6 +3,7 @@ from __future__ import annotations from docutils import nodes +from sphinx_autodoc_badges import BadgeNode, build_badge from sphinx_autodoc_pytest_fixtures._constants import _SUPPRESSED_SCOPES from sphinx_autodoc_pytest_fixtures._css import _CSS @@ -27,10 +28,7 @@ def _build_badge_group_node( deprecated: bool = False, show_fixture_badge: bool = True, ) -> nodes.inline: - """Return a badge group as portable ``nodes.abbreviation`` nodes. - - Each badge renders as ```` in HTML, providing hover - tooltips. Non-HTML builders fall back to plain text. + """Return a badge group with shared BadgeNode children. Badge slots (left-to-right in visual order): @@ -51,87 +49,74 @@ def _build_badge_group_node( deprecated : bool When True, renders a deprecated badge at slot 0 (leftmost). show_fixture_badge : bool - When False, suppresses the FIXTURE badge at slot 3. Use in contexts - where the fixture type is already implied (e.g. an index table). + When False, suppresses the FIXTURE badge at slot 3. Returns ------- nodes.inline - Badge group container with abbreviation badge children. + Badge group container with BadgeNode children. """ group = nodes.inline(classes=[_CSS.BADGE_GROUP]) - badges: list[nodes.abbreviation] = [] + badges: list[BadgeNode] = [] - # Slot 0 — deprecated badge (leftmost when present) if deprecated: badges.append( - nodes.abbreviation( - "deprecated", + build_badge( "deprecated", - explanation=_BADGE_TOOLTIPS["deprecated"], + tooltip=_BADGE_TOOLTIPS["deprecated"], classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.DEPRECATED], + fill="outline", ) ) - # Slot 1 — scope badge (only non-function scope) if scope and scope not in _SUPPRESSED_SCOPES: badges.append( - nodes.abbreviation( + build_badge( scope, - scope, - explanation=_BADGE_TOOLTIPS.get(scope, f"Scope: {scope}"), + tooltip=_BADGE_TOOLTIPS.get(scope, f"Scope: {scope}"), classes=[_CSS.BADGE, _CSS.BADGE_SCOPE, _CSS.scope(scope)], + fill="outline", ) ) - # Slot 2 — kind or autouse badge if autouse: badges.append( - nodes.abbreviation( - "auto", + build_badge( "auto", - explanation=_BADGE_TOOLTIPS["autouse"], + tooltip=_BADGE_TOOLTIPS["autouse"], classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.AUTOUSE], + fill="outline", ) ) elif kind == "factory": badges.append( - nodes.abbreviation( + build_badge( "factory", - "factory", - explanation=_BADGE_TOOLTIPS["factory"], + tooltip=_BADGE_TOOLTIPS["factory"], classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.FACTORY], + fill="outline", ) ) elif kind == "override_hook": badges.append( - nodes.abbreviation( - "override", + build_badge( "override", - explanation=_BADGE_TOOLTIPS["override_hook"], + tooltip=_BADGE_TOOLTIPS["override_hook"], classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.OVERRIDE], + fill="outline", ) ) - # Slot 3 — fixture badge (rightmost, suppressed in index table context) if show_fixture_badge: badges.append( - nodes.abbreviation( + build_badge( "fixture", - "fixture", - explanation=_BADGE_TOOLTIPS["fixture"], + tooltip=_BADGE_TOOLTIPS["fixture"], classes=[_CSS.BADGE, _CSS.BADGE_FIXTURE], + fill="outline", ) ) - # Make badges focusable for touch/keyboard tooltip accessibility. - # Sphinx's built-in visit_abbreviation does NOT emit tabindex — our - # custom visitor override (_visit_abbreviation_html) handles it. - for badge in badges: - badge["tabindex"] = "0" - - # Interleave with text separators for non-HTML builders (CSS gap - # handles spacing in HTML; text/LaTeX/man builders need explicit spaces). for i, badge in enumerate(badges): group += badge if i < len(badges) - 1: diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py index 3c66a18..994ff9e 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py @@ -18,7 +18,8 @@ if t.TYPE_CHECKING: from sphinx.application import Sphinx from sphinx.domains.python import PythonDomain - from sphinx.writers.html5 import HTML5Translator + + pass logger = sphinx_logging.getLogger(__name__) @@ -295,31 +296,3 @@ def _on_doctree_resolved( # Resolve autofixture-index placeholders for idx_node in list(doctree.findall(autofixture_index_node)): _resolve_fixture_index(idx_node, store, py_domain, app, docname) - - -def _visit_abbreviation_html( - self: HTML5Translator, - node: nodes.abbreviation, -) -> None: - """Emit ```` with ``tabindex`` when present. - - Sphinx's built-in ``visit_abbreviation`` only passes ``explanation`` \u2192 - ``title``. It silently drops all other node attributes (including - ``tabindex``). This override is a strict superset: non-badge abbreviation - nodes produce byte-identical output because the ``tabindex`` guard only - fires when the attribute is explicitly set. - """ - attrs: dict[str, t.Any] = {} - if node.get("explanation"): - attrs["title"] = node["explanation"] - if node.get("tabindex"): - attrs["tabindex"] = node["tabindex"] - self.body.append(self.starttag(node, "abbr", "", **attrs)) - - -def _depart_abbreviation_html( - self: HTML5Translator, - node: nodes.abbreviation, -) -> None: - """Close the ```` tag.""" - self.body.append("") diff --git a/pyproject.toml b/pyproject.toml index 95e1dd0..517383f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ sphinx-autodoc-docutils = { workspace = true } sphinx-autodoc-sphinx = { workspace = true } sphinx-autodoc-api-style = { workspace = true } sphinx-autodoc-fastmcp = { workspace = true } +sphinx-autodoc-badges = { workspace = true } gp-sphinx = { workspace = true } [dependency-groups] @@ -35,6 +36,7 @@ dev = [ "sphinx-autodoc-sphinx", "sphinx-autodoc-api-style", "sphinx-autodoc-fastmcp", + "sphinx-autodoc-badges", # Docs "sphinx-autobuild", # Testing diff --git a/tests/ext/api_style/test_api_style.py b/tests/ext/api_style/test_api_style.py index efa9f31..c2adbf0 100644 --- a/tests/ext/api_style/test_api_style.py +++ b/tests/ext/api_style/test_api_style.py @@ -6,6 +6,7 @@ from docutils import nodes from sphinx import addnodes +from sphinx_autodoc_badges import BadgeNode from sphinx_autodoc_api_style._badges import ( _MOD_ORDER, @@ -62,7 +63,7 @@ def test_badge_group_returns_inline() -> None: def test_badge_group_type_badge_present() -> None: """Type badge is present when show_type_badge is True (default).""" group = build_badge_group("class", modifiers=frozenset()) - badges = list(group.findall(nodes.abbreviation)) + badges = list(group.findall(BadgeNode)) assert len(badges) == 1 assert badges[0].astext() == "class" assert _CSS.BADGE_TYPE in badges[0]["classes"] @@ -72,7 +73,7 @@ def test_badge_group_type_badge_present() -> None: def test_badge_group_type_badge_suppressed() -> None: """Type badge is absent when show_type_badge is False.""" group = build_badge_group("function", modifiers=frozenset(), show_type_badge=False) - badges = list(group.findall(nodes.abbreviation)) + badges = list(group.findall(BadgeNode)) assert len(badges) == 0 @@ -82,7 +83,7 @@ def test_badge_group_with_modifiers() -> None: "method", modifiers=frozenset({"async", "abstract"}), ) - badges = list(group.findall(nodes.abbreviation)) + badges = list(group.findall(BadgeNode)) labels = [b.astext() for b in badges] assert "abstract" in labels assert "async" in labels @@ -95,7 +96,7 @@ def test_badge_group_modifier_order() -> None: """Modifiers appear in the canonical order defined by _MOD_ORDER.""" all_mods = frozenset(_MOD_ORDER) group = build_badge_group("function", modifiers=all_mods) - badges = list(group.findall(nodes.abbreviation)) + badges = list(group.findall(BadgeNode)) mod_labels = [b.astext() for b in badges if _CSS.BADGE_MOD in b["classes"]] expected = list(_MOD_ORDER) assert mod_labels == expected @@ -107,7 +108,7 @@ def test_badge_group_tabindex() -> None: "function", modifiers=frozenset({"async"}), ) - for badge in group.findall(nodes.abbreviation): + for badge in group.findall(BadgeNode): assert badge.get("tabindex") == "0" @@ -117,12 +118,12 @@ def test_badge_group_tooltips() -> None: "function", modifiers=frozenset({"async"}), ) - badges = list(group.findall(nodes.abbreviation)) + badges = list(group.findall(BadgeNode)) async_badge = [b for b in badges if b.astext() == "async"][0] - assert async_badge["explanation"] == _MOD_TOOLTIPS["async"] + assert async_badge["badge_tooltip"] == _MOD_TOOLTIPS["async"] func_badge = [b for b in badges if b.astext() == "function"][0] - assert func_badge["explanation"] == _TYPE_TOOLTIPS["function"] + assert func_badge["badge_tooltip"] == _TYPE_TOOLTIPS["function"] def test_badge_group_text_separators() -> None: @@ -146,7 +147,7 @@ def test_badge_group_single_badge_no_separator() -> None: def test_badge_group_deprecated() -> None: """Deprecated modifier badge uses DEPRECATED CSS class.""" group = build_badge_group("class", modifiers=frozenset({"deprecated"})) - badges = list(group.findall(nodes.abbreviation)) + badges = list(group.findall(BadgeNode)) dep_badge = [b for b in badges if b.astext() == "deprecated"][0] assert _CSS.DEPRECATED in dep_badge["classes"] assert _CSS.BADGE_MOD in dep_badge["classes"] @@ -156,7 +157,7 @@ def test_badge_group_all_type_labels() -> None: """All handled objtypes produce a valid type badge label.""" for objtype in _HANDLED_OBJTYPES: group = build_badge_group(objtype, modifiers=frozenset()) - badges = list(group.findall(nodes.abbreviation)) + badges = list(group.findall(BadgeNode)) assert len(badges) >= 1 label = badges[-1].astext() assert label == _TYPE_LABELS.get(objtype, objtype) @@ -298,9 +299,9 @@ def test_inject_badges_idempotent() -> None: sig = addnodes.desc_signature() sig += addnodes.desc_name("", "my_func") _inject_badges(sig, "function") - badge_count_1 = len(list(sig.findall(nodes.abbreviation))) + badge_count_1 = len(list(sig.findall(BadgeNode))) _inject_badges(sig, "function") - badge_count_2 = len(list(sig.findall(nodes.abbreviation))) + badge_count_2 = len(list(sig.findall(BadgeNode))) assert badge_count_1 == badge_count_2 @@ -334,7 +335,7 @@ def test_inject_badges_detects_deprecated_parent() -> None: _inject_badges(sig, "function") - badges = list(sig.findall(nodes.abbreviation)) + badges = list(sig.findall(BadgeNode)) labels = [b.astext() for b in badges] assert "deprecated" in labels diff --git a/tests/ext/badges/__init__.py b/tests/ext/badges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ext/badges/test_badges.py b/tests/ext/badges/test_badges.py new file mode 100644 index 0000000..99019c6 --- /dev/null +++ b/tests/ext/badges/test_badges.py @@ -0,0 +1,152 @@ +"""Tests for sphinx_autodoc_badges.""" + +from __future__ import annotations + +from docutils import nodes +from sphinx_autodoc_badges import ( + BadgeNode, + build_badge, + build_badge_group, + build_toolbar, +) +from sphinx_autodoc_badges._css import SAB + + +def test_badge_node_is_inline_subclass() -> None: + """BadgeNode subclasses nodes.inline for MRO fallback.""" + assert issubclass(BadgeNode, nodes.inline) + b = BadgeNode("test") + assert isinstance(b, nodes.inline) + + +def test_badge_node_has_base_class() -> None: + """BadgeNode always gets sab-badge class.""" + b = BadgeNode("readonly") + assert SAB.BADGE in b["classes"] + + +def test_badge_node_text() -> None: + """BadgeNode contains text child.""" + b = BadgeNode("hello") + assert b.astext() == "hello" + + +def test_badge_node_empty() -> None: + """Empty badge for icon-only has no text children.""" + b = BadgeNode("") + assert b.astext() == "" + + +def test_badge_node_tooltip() -> None: + """Tooltip stored as badge_tooltip attribute.""" + b = BadgeNode("x", badge_tooltip="tip") + assert b["badge_tooltip"] == "tip" + + +def test_badge_node_icon() -> None: + """Icon stored as badge_icon attribute.""" + b = BadgeNode("x", badge_icon="\U0001f50d") + assert b["badge_icon"] == "\U0001f50d" + + +def test_badge_node_style_icon_only() -> None: + """Icon-only style adds sab-icon-only class.""" + b = BadgeNode("", badge_style="icon-only") + assert SAB.ICON_ONLY in b["classes"] + + +def test_badge_node_style_inline_icon() -> None: + """Inline-icon style adds sab-inline-icon class.""" + b = BadgeNode("", badge_style="inline-icon") + assert SAB.INLINE_ICON in b["classes"] + + +def test_badge_node_tabindex() -> None: + """Tabindex defaults to '0'.""" + b = BadgeNode("x") + assert b["tabindex"] == "0" + + +def test_badge_node_extra_classes() -> None: + """Extra classes are appended.""" + b = BadgeNode("x", classes=["smf-safety-readonly", "smf-badge--safety"]) + assert "smf-safety-readonly" in b["classes"] + assert SAB.BADGE in b["classes"] + + +def test_build_badge_basic() -> None: + """build_badge creates a BadgeNode with correct text.""" + b = build_badge("readonly", tooltip="Read-only") + assert isinstance(b, BadgeNode) + assert b.astext() == "readonly" + assert b["badge_tooltip"] == "Read-only" + + +def test_build_badge_icon_only() -> None: + """build_badge with icon-only style.""" + b = build_badge("", style="icon-only", classes=["smf-safety-readonly"]) + assert SAB.ICON_ONLY in b["classes"] + assert "smf-safety-readonly" in b["classes"] + assert b.astext() == "" + + +def test_build_badge_outline() -> None: + """build_badge with outline fill adds sab-outline class.""" + b = build_badge("function", fill="outline", classes=["gas-type-function"]) + assert SAB.OUTLINE in b["classes"] + assert "gas-type-function" in b["classes"] + + +def test_build_badge_group() -> None: + """build_badge_group wraps badges with spacing.""" + b1 = build_badge("a") + b2 = build_badge("b") + g = build_badge_group([b1, b2], classes=["smf-badge-group"]) + assert SAB.BADGE_GROUP in g["classes"] + assert "smf-badge-group" in g["classes"] + badges = list(g.findall(BadgeNode)) + assert len(badges) == 2 + + +def test_build_toolbar() -> None: + """build_toolbar wraps a group in a toolbar container.""" + g = build_badge_group([build_badge("x")]) + t = build_toolbar(g, classes=["smf-toolbar"]) + assert SAB.TOOLBAR in t["classes"] + assert "smf-toolbar" in t["classes"] + + +def test_badge_inside_reference() -> None: + """BadgeNode can be nested inside a reference node.""" + ref = nodes.reference("", "", internal=True, refuri="#test") + badge = build_badge("readonly", classes=["smf-safety-readonly"]) + ref += badge + found = list(ref.findall(BadgeNode)) + assert len(found) == 1 + + +def test_badge_inside_literal() -> None: + """BadgeNode can be nested inside a literal (code chip).""" + code = nodes.literal("", "") + badge = build_badge("", style="inline-icon", classes=["smf-safety-readonly"]) + code += badge + code += nodes.Text("capture_pane") + found = list(code.findall(BadgeNode)) + assert len(found) == 1 + + +def test_badge_next_to_literal_in_reference() -> None: + """Icon-only badge + literal side by side inside a reference.""" + ref = nodes.reference("", "", internal=True, refuri="#test") + badge = build_badge("", style="icon-only", classes=["smf-safety-readonly"]) + ref += badge + ref += nodes.literal("", "capture_pane") + assert len(list(ref.findall(BadgeNode))) == 1 + assert len(list(ref.findall(nodes.literal))) == 1 + + +def test_css_constants() -> None: + """CSS constants use sab- prefix.""" + assert SAB.PREFIX == "sab" + assert SAB.BADGE == "sab-badge" + assert SAB.ICON_ONLY == "sab-icon-only" diff --git a/tests/ext/fastmcp/test_fastmcp.py b/tests/ext/fastmcp/test_fastmcp.py index fecb68a..c969f27 100644 --- a/tests/ext/fastmcp/test_fastmcp.py +++ b/tests/ext/fastmcp/test_fastmcp.py @@ -3,6 +3,7 @@ from __future__ import annotations from docutils import nodes +from sphinx_autodoc_badges import BadgeNode from sphinx_autodoc_fastmcp._badges import build_safety_badge, build_tool_badge_group from sphinx_autodoc_fastmcp._css import _CSS @@ -22,19 +23,34 @@ def test_css_prefix() -> None: def test_badge_group_contains_tool_type() -> None: """Tool badge group includes safety + type badge.""" group = build_tool_badge_group("readonly") - assert _CSS.BADGE_GROUP in group["classes"] - abbrs = list(group.findall(nodes.abbreviation)) - assert len(abbrs) == 2 - assert "tool" in abbrs[-1].astext() + assert "sab-badge-group" in group["classes"] + badges = list(group.findall(BadgeNode)) + assert len(badges) == 2 + assert "tool" in badges[-1].astext() -def test_safety_badge_abbreviation() -> None: - """Safety badge is an abbreviation node.""" +def test_safety_badge_is_badge_node() -> None: + """Safety badge is a BadgeNode (shared package).""" b = build_safety_badge("mutating") - assert isinstance(b, nodes.abbreviation) + assert isinstance(b, BadgeNode) + assert isinstance(b, nodes.inline) assert b.astext() == "mutating" +def test_safety_badge_has_classes() -> None: + """Safety badge has sab-badge + smf safety classes.""" + b = build_safety_badge("readonly") + assert "sab-badge" in b["classes"] + assert "smf-safety-readonly" in b["classes"] + + +def test_safety_badge_icon_only() -> None: + """Icon-only safety badge has sab-icon-only class and empty text.""" + b = build_safety_badge("readonly", icon_only=True) + assert "sab-icon-only" in b["classes"] + assert b.astext() == "" + + def test_tool_placeholder_node() -> None: """Placeholder stores hyphenated ref target.""" n = _tool_ref_placeholder("", reftarget="list-sessions", show_badge=True) diff --git a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures.py b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures.py index 71b9f1b..2fdc0e0 100644 --- a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures.py +++ b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures.py @@ -8,6 +8,7 @@ import pytest from docutils import nodes +from sphinx_autodoc_badges import BadgeNode import sphinx_autodoc_pytest_fixtures import sphinx_autodoc_pytest_fixtures._store @@ -498,14 +499,11 @@ def test_build_badge_group_node_factory_session() -> None: def test_build_badge_group_node_has_tabindex() -> None: """All badge abbreviation nodes have tabindex='0' for touch accessibility.""" - from docutils import nodes node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( "session", "factory", True ) - abbreviations = [ - child for child in node.children if isinstance(child, nodes.abbreviation) - ] + abbreviations = [child for child in node.children if isinstance(child, BadgeNode)] assert len(abbreviations) > 0 for abbr in abbreviations: assert abbr.get("tabindex") == "0", ( @@ -875,7 +873,6 @@ def my_fixture(pytestconfig: t.Any) -> str: def test_has_authored_example_with_rubric() -> None: """Authored Example rubric suppresses auto-generated snippets.""" - from docutils import nodes content = nodes.container() content += nodes.paragraph("", "Some intro text.") @@ -886,7 +883,6 @@ def test_has_authored_example_with_rubric() -> None: def test_has_authored_example_with_doctest() -> None: """Doctest blocks count as authored examples.""" - from docutils import nodes content = nodes.container() content += nodes.doctest_block("", ">>> 1 + 1\n2") @@ -895,7 +891,6 @@ def test_has_authored_example_with_doctest() -> None: def test_has_authored_example_without() -> None: """No authored examples — auto-snippet should still be generated.""" - from docutils import nodes content = nodes.container() content += nodes.paragraph("", "Just a description.") @@ -904,7 +899,6 @@ def test_has_authored_example_without() -> None: def test_has_authored_example_nested_not_detected() -> None: """Nested rubrics inside admonitions are not detected (non-recursive).""" - from docutils import nodes content = nodes.container() admonition = nodes.note() @@ -928,7 +922,6 @@ def test_build_usage_snippet_resource_returns_none() -> None: def test_build_usage_snippet_autouse_returns_note() -> None: """Autouse fixtures return a nodes.note admonition.""" - from docutils import nodes result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( "auto_cleanup", None, "resource", "function", autouse=True @@ -939,7 +932,6 @@ def test_build_usage_snippet_autouse_returns_note() -> None: def test_build_usage_snippet_factory_returns_literal_block() -> None: """Factory fixtures produce a literal_block with instantiation pattern.""" - from docutils import nodes result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( "TestServer", "Server", "factory", "function", autouse=False @@ -953,7 +945,6 @@ def test_build_usage_snippet_factory_returns_literal_block() -> None: def test_build_usage_snippet_override_hook_returns_conftest() -> None: """Override hook fixtures produce a conftest.py snippet.""" - from docutils import nodes result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( "home_user", "str", "override_hook", "function", autouse=False @@ -1090,7 +1081,7 @@ def test_deprecated_badge_renders_at_slot_zero() -> None: node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( "session", "resource", False, deprecated=True ) - badges = [c for c in node.children if isinstance(c, nodes.abbreviation)] + badges = [c for c in node.children if isinstance(c, BadgeNode)] assert len(badges) >= 2 # First badge should be "deprecated" assert badges[0].astext() == "deprecated" @@ -1103,7 +1094,7 @@ def test_deprecated_badge_absent_when_not_deprecated() -> None: node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( "session", "resource", False ) - badges = [c for c in node.children if isinstance(c, nodes.abbreviation)] + badges = [c for c in node.children if isinstance(c, BadgeNode)] texts = [b.astext() for b in badges] assert "deprecated" not in texts diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index a35f45e..6936937 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -22,6 +22,7 @@ def test_workspace_packages_lists_publishable_packages() -> None: "gp-sphinx", "sphinx-argparse-neo", "sphinx-autodoc-api-style", + "sphinx-autodoc-badges", "sphinx-autodoc-docutils", "sphinx-autodoc-fastmcp", "sphinx-autodoc-pytest-fixtures", diff --git a/uv.lock b/uv.lock index 6feb2d1..6184531 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "gp-sphinx-workspace", "sphinx-argparse-neo", "sphinx-autodoc-api-style", + "sphinx-autodoc-badges", "sphinx-autodoc-docutils", "sphinx-autodoc-fastmcp", "sphinx-autodoc-pytest-fixtures", @@ -468,6 +469,7 @@ dev = [ { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx-autodoc-api-style" }, + { name = "sphinx-autodoc-badges" }, { name = "sphinx-autodoc-docutils" }, { name = "sphinx-autodoc-fastmcp" }, { name = "sphinx-autodoc-pytest-fixtures" }, @@ -496,6 +498,7 @@ dev = [ { name = "sphinx-argparse-neo", editable = "packages/sphinx-argparse-neo" }, { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", editable = "packages/sphinx-autodoc-api-style" }, + { name = "sphinx-autodoc-badges", editable = "packages/sphinx-autodoc-badges" }, { name = "sphinx-autodoc-docutils", editable = "packages/sphinx-autodoc-docutils" }, { name = "sphinx-autodoc-fastmcp", editable = "packages/sphinx-autodoc-fastmcp" }, { name = "sphinx-autodoc-pytest-fixtures", editable = "packages/sphinx-autodoc-pytest-fixtures" }, @@ -1272,6 +1275,22 @@ wheels = [ name = "sphinx-autodoc-api-style" version = "0.0.1a5" source = { editable = "packages/sphinx-autodoc-api-style" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-badges" }, +] + +[package.metadata] +requires-dist = [ + { name = "sphinx" }, + { name = "sphinx-autodoc-badges", editable = "packages/sphinx-autodoc-badges" }, +] + +[[package]] +name = "sphinx-autodoc-badges" +version = "0.0.1a5" +source = { editable = "packages/sphinx-autodoc-badges" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -1299,10 +1318,14 @@ source = { editable = "packages/sphinx-autodoc-fastmcp" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-badges" }, ] [package.metadata] -requires-dist = [{ name = "sphinx" }] +requires-dist = [ + { name = "sphinx" }, + { name = "sphinx-autodoc-badges", editable = "packages/sphinx-autodoc-badges" }, +] [[package]] name = "sphinx-autodoc-pytest-fixtures" @@ -1312,12 +1335,14 @@ dependencies = [ { name = "pytest" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-badges" }, ] [package.metadata] requires-dist = [ { name = "pytest" }, { name = "sphinx" }, + { name = "sphinx-autodoc-badges", editable = "packages/sphinx-autodoc-badges" }, ] [[package]] From 29af5b79353146896521af3042d1c702d6ac12e1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 17:28:53 -0500 Subject: [PATCH 05/14] badges+fastmcp(css): Align badge styling with sphinx-gptheme why: Consolidate iterative CSS work into one logical change: gptheme parity for safety and MCP tool badges, TOC layout, robust colors when theme CSS loads later, and subtle inset depth on solid pills. what: - sphinx_autodoc_badges: --sab-* fallbacks, buff shadow, TOC compact badges with icons - sphinx_autodoc_fastmcp: matte palette, borders, metrics, dark theme type tool --- .../_static/css/sphinx_autodoc_badges.css | 50 +++-- .../_static/css/sphinx_autodoc_fastmcp.css | 183 ++++++++++++++---- 2 files changed, 178 insertions(+), 55 deletions(-) diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css index 8a2f4b8..46d5757 100644 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css @@ -5,6 +5,14 @@ * (--sab-bg, --sab-fg, --sab-border). */ +:root { + /* Subtle “buffed pill”: top highlight + soft inner shade (light UI / white page bg) */ + --sab-buff-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), + inset 0 -1px 2px rgba(0, 0, 0, 0.12); + --sab-buff-shadow-dark-ui: inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 2px rgba(0, 0, 0, 0.28); +} + /* ── Base badge ─────────────────────────────────────────── */ .sab-badge { display: inline-flex; @@ -17,8 +25,10 @@ padding: var(--sab-padding-v, 0.35em) var(--sab-padding-h, 0.65em); border-radius: var(--sab-radius, 0.25rem); border: var(--sab-border, none); - background: var(--sab-bg); - color: var(--sab-fg); + /* Use background-color + fallbacks so unset --sab-* does not interact badly + * with later theme shorthands (e.g. sphinx-design loaded after extensions). */ + background-color: var(--sab-bg, transparent); + color: var(--sab-fg, inherit); vertical-align: middle; white-space: nowrap; text-align: center; @@ -28,6 +38,20 @@ box-sizing: border-box; } +.sab-badge:not(.sab-outline):not(.sab-inline-icon) { + box-shadow: var(--sab-buff-shadow); +} + +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) .sab-badge:not(.sab-outline):not(.sab-inline-icon) { + box-shadow: var(--sab-buff-shadow-dark-ui); + } +} + +body[data-theme="dark"] .sab-badge:not(.sab-outline):not(.sab-inline-icon) { + box-shadow: var(--sab-buff-shadow-dark-ui); +} + .sab-badge:focus-visible { outline: 2px solid var(--color-link, #2962ff); outline-offset: 2px; @@ -146,9 +170,13 @@ code.docutils .sab-badge.sab-inline-icon:last-child { } .body p .sab-badge, +.body li .sab-badge, .body td .sab-badge, +.body a .sab-badge, [role="main"] p .sab-badge, -[role="main"] td .sab-badge { +[role="main"] li .sab-badge, +[role="main"] td .sab-badge, +[role="main"] a .sab-badge { font-size: 0.62rem; padding: 0.12rem 0.32rem; } @@ -187,7 +215,7 @@ code.docutils .sab-badge.sab-inline-icon:last-child { /* ── TOC sidebar: compact badges ───────────────────────── * Smaller badges that still show text (matching production). * Container wrappers collapse to inline flow. - * Emoji icons hidden at this size. + * Emoji icons shown at compact size (data-icon / ::before). */ .toc-tree .sab-toolbar, .toc-tree .sab-badge-group { @@ -200,17 +228,15 @@ code.docutils .sab-badge.sab-inline-icon:last-child { } .toc-tree .sab-badge { - font-size: 0.55rem; - padding: 0.08rem 0.22rem; - gap: 0; + font-size: 0.58rem; + padding: 0.1rem 0.25rem; + gap: 0.06rem; vertical-align: middle; line-height: 1.1; } .toc-tree .sab-badge::before { - display: none; -} - -.toc-tree .sab-badge[data-icon]::before { - display: none; + font-size: 0.5rem; + margin-right: 0.08rem; + flex-shrink: 0; } diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css index 6d4b925..446ed9c 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_static/css/sphinx_autodoc_fastmcp.css @@ -1,64 +1,163 @@ /* sphinx_autodoc_fastmcp — color layer for FastMCP tool badges. * - * Base metrics, icon-only, TOC dots, context sizing, heading flex - * come from sphinx_autodoc_badges.css (shared package). - * This file provides colors, tool section card styling, and emoji icons. + * Base metrics come from sphinx_autodoc_badges.css. This file matches + * sphinx-gptheme custom.css rules for .sd-badge[aria-label^="Safety tier:"] + * (font metrics, !important colors, theme --badge-safety-* variables). + * + * Safety palette: same tokens as sphinx_gptheme/theme/static/css/custom.css + * so readonly / mutating / destructive match production regardless of load order. */ :root { - --smf-readonly-bg: #28a745; - --smf-readonly-fg: #fff; - --smf-mutating-bg: #f0b37e; - --smf-mutating-fg: #1a1a1a; - --smf-destructive-bg: #dc3545; - --smf-destructive-fg: #fff; + --badge-safety-readonly-bg: #1f7a3f; + --badge-safety-readonly-border: #2a8d4d; + --badge-safety-readonly-text: #f3fff7; + --badge-safety-mutating-bg: #b96a1a; + --badge-safety-mutating-border: #cf7a23; + --badge-safety-mutating-text: #fff8ef; + --badge-safety-destructive-bg: #b4232c; + --badge-safety-destructive-border: #cb3640; + --badge-safety-destructive-text: #fff5f5; --smf-type-tool-bg: #0e7490; --smf-type-tool-fg: #fff; + --smf-type-tool-border: #0f766e; +} + +/* ── sphinx-gptheme parity: same box model as .sd-badge + safety tier ── + * See sphinx_gptheme/theme/static/css/custom.css (.sd-badge, h2/h3 .sd-badge, …) + */ +.sab-badge.smf-badge--safety { + display: inline-flex !important; + align-items: center; + vertical-align: middle; + font-size: 0.67rem; + font-weight: 700; + line-height: 1; + letter-spacing: 0.01em; + padding: 0.16rem 0.4rem; + border-radius: 0.22rem; + user-select: none; + -webkit-user-select: none; + gap: 0.28rem; + border: 1px solid transparent; + box-sizing: border-box; +} + +.sab-badge.smf-badge--safety::before { + font-style: normal; + font-weight: normal; + font-size: 1em; + line-height: 1; + flex-shrink: 0; +} + +/* Tool card titles: match h2/h3 .sd-badge[aria-label^="Safety tier:"] */ +h2.smf-tool-title .sab-badge.smf-badge--safety, +h3.smf-tool-title .sab-badge.smf-badge--safety, +h4.smf-tool-title .sab-badge.smf-badge--safety, +.smf-tool-title .sab-badge.smf-badge--safety { + font-size: 0.68rem; + padding: 0.17rem 0.4rem; +} + +/* Type badge: same base scale as .sd-badge (custom.css) */ +.sab-badge.smf-badge--type { + display: inline-flex !important; + align-items: center; + vertical-align: middle; + font-size: 0.67rem; + font-weight: 600; + line-height: 1; + letter-spacing: 0.02em; + padding: 0.16rem 0.4rem; + border-radius: 0.22rem; + user-select: none; + -webkit-user-select: none; + box-sizing: border-box; +} + +h2.smf-tool-title .sab-badge.smf-badge--type, +h3.smf-tool-title .sab-badge.smf-badge--type, +h4.smf-tool-title .sab-badge.smf-badge--type, +.smf-tool-title .sab-badge.smf-badge--type { + font-size: 0.68rem; + padding: 0.17rem 0.4rem; +} + +/* + * Matte safety colors: literal hex + !important so sphinx-design (loaded after + * this file) cannot skew var() resolution or shorthands. Keeps parity with + * :root --badge-safety-* above; override there + copy here if you change the palette. + */ +.sab-badge.smf-safety-readonly:not(.sab-inline-icon) { + background-color: #1f7a3f !important; + color: #f3fff7 !important; + border: 1px solid #2a8d4d !important; + box-shadow: var(--sab-buff-shadow) !important; +} + +.sab-badge.smf-safety-mutating:not(.sab-inline-icon) { + background-color: #b96a1a !important; + color: #fff8ef !important; + border: 1px solid #cf7a23 !important; + box-shadow: var(--sab-buff-shadow) !important; +} + +.sab-badge.smf-safety-destructive:not(.sab-inline-icon) { + background-color: #b4232c !important; + color: #fff5f5 !important; + border: 1px solid #cb3640 !important; + box-shadow: var(--sab-buff-shadow) !important; +} + +/* MCP "tool": white label (never use --sd-color-info-text; it is dark on light teal) */ +.sab-badge.smf-type-tool:not(.sab-inline-icon) { + background-color: #0e7490 !important; + color: #ffffff !important; + border: 1px solid #0f766e !important; + box-shadow: var(--sab-buff-shadow) !important; } @media (prefers-color-scheme: dark) { - body:not([data-theme="light"]) { - --smf-readonly-bg: #2ea043; - --smf-mutating-bg: #d4a574; - --smf-destructive-bg: #f85149; - --smf-type-tool-bg: #22d3ee; - --smf-type-tool-fg: #0f172a; + body:not([data-theme="light"]) .sab-badge.smf-safety-readonly:not(.sab-inline-icon), + body:not([data-theme="light"]) .sab-badge.smf-safety-mutating:not(.sab-inline-icon), + body:not([data-theme="light"]) .sab-badge.smf-safety-destructive:not(.sab-inline-icon), + body:not([data-theme="light"]) .sab-badge.smf-type-tool:not(.sab-inline-icon) { + box-shadow: var(--sab-buff-shadow-dark-ui) !important; } -} -body[data-theme="dark"] { - --smf-readonly-bg: #2ea043; - --smf-mutating-bg: #d4a574; - --smf-destructive-bg: #f85149; - --smf-type-tool-bg: #22d3ee; - --smf-type-tool-fg: #0f172a; + body:not([data-theme="light"]) .sab-badge.smf-type-tool:not(.sab-inline-icon) { + background-color: #0d9488 !important; + color: #ffffff !important; + border: 1px solid #14b8a6 !important; + } } -/* ── Safety tier colors ─────────────────────────────────── */ -.smf-safety-readonly { - --sab-bg: var(--smf-readonly-bg); - --sab-fg: var(--smf-readonly-fg); +body[data-theme="dark"] .sab-badge.smf-safety-readonly:not(.sab-inline-icon), +body[data-theme="dark"] .sab-badge.smf-safety-mutating:not(.sab-inline-icon), +body[data-theme="dark"] .sab-badge.smf-safety-destructive:not(.sab-inline-icon), +body[data-theme="dark"] .sab-badge.smf-type-tool:not(.sab-inline-icon) { + box-shadow: var(--sab-buff-shadow-dark-ui) !important; } -.smf-safety-mutating { - --sab-bg: var(--smf-mutating-bg); - --sab-fg: var(--smf-mutating-fg); +body[data-theme="dark"] .sab-badge.smf-type-tool:not(.sab-inline-icon) { + background-color: #0d9488 !important; + color: #ffffff !important; + border: 1px solid #14b8a6 !important; } -.smf-safety-destructive { - --sab-bg: var(--smf-destructive-bg); - --sab-fg: var(--smf-destructive-fg); +/* ── Emoji when data-icon absent (unicode); data-icon wins via badges base ── */ +.smf-safety-readonly:not([data-icon])::before { + content: "\1F50D"; } -.smf-type-tool { - --sab-bg: var(--smf-type-tool-bg); - --sab-fg: var(--smf-type-tool-fg); +.smf-safety-mutating:not([data-icon])::before { + content: "\270F\FE0F"; } -/* ── Emoji icons via ::before ───────────────────────────── */ -.smf-safety-readonly::before { content: "\1F50D"; } -.smf-safety-mutating::before { content: "\270F\FE0F"; } -.smf-safety-destructive::before { content: "\1F4A3"; } +.smf-safety-destructive:not([data-icon])::before { + content: "\1F4A3"; +} /* ── Tool section card ──────────────────────────────────── */ section.smf-tool-section { @@ -79,11 +178,12 @@ section.smf-tool-section > h6 { margin: 0; } +/* Match custom.css h2/h3:has(> .sd-badge[...]) gap between code and badges */ section.smf-tool-section > .smf-tool-title { display: flex; align-items: center; flex-wrap: wrap; - gap: 0.35rem; + gap: 0.45rem; background: var(--color-background-secondary); border-bottom: 1px solid var(--color-background-border); padding: 0.5rem 0.75rem 0.5rem 1rem; @@ -100,7 +200,6 @@ section.smf-tool-section > .smf-tool-title .sab-toolbar { order: 99; } -/* Body: docstring, returns, MyST blocks, fastmcp-tool-input tables */ section.smf-tool-section > .smf-tool-title ~ * { padding-left: 1rem; padding-right: 1rem; @@ -116,12 +215,10 @@ section.smf-tool-section > .smf-tool-title ~ *:last-child { padding-bottom: 0.75rem; } -/* ── TOC sidebar: hide type badge, keep only safety ─────── */ .toc-tree .smf-badge--type { display: none !important; } -/* ── Visually hidden title (for ToC anchors) ────────────── */ .smf-visually-hidden { position: absolute; width: 1px; From 90cb03a6aa4f4f316b670eea178dcfb1fb935cef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 19:04:14 -0500 Subject: [PATCH 06/14] docs(packages[badges]): Full docs page with autodoc API, live demos, CSS reference why: The stub page lacked API docs, intersphinx links, badge demos, and CSS variable/class documentation that sibling package pages provide. what: - conf.py: sys.path + sphinx_autodoc_badges + sab_demo extensions - sab_demo.py: directive rendering every badge variant via real API - sphinx-autodoc-badges.md: setup/intersphinx, autodoc API, live demos, CSS custom properties, class reference, context sizing, downstream table --- docs/_ext/sab_demo.py | 139 ++++++++++++++ docs/_static/css/sab_demo.css | 9 + docs/conf.py | 6 + docs/packages/sphinx-autodoc-badges.md | 243 +++++++++++++++++++++++-- 4 files changed, 382 insertions(+), 15 deletions(-) create mode 100644 docs/_ext/sab_demo.py create mode 100644 docs/_static/css/sab_demo.css diff --git a/docs/_ext/sab_demo.py b/docs/_ext/sab_demo.py new file mode 100644 index 0000000..46ef580 --- /dev/null +++ b/docs/_ext/sab_demo.py @@ -0,0 +1,139 @@ +"""Live badge demo directive for the sphinx-autodoc-badges docs page. + +Renders every badge variant using the real ``build_badge`` / +``build_badge_group`` / ``build_toolbar`` API so the page exercises +the actual Python + CSS pipeline. +""" + +from __future__ import annotations + +from docutils import nodes +from sphinx.application import Sphinx +from sphinx.util.docutils import SphinxDirective +from sphinx_autodoc_badges import build_badge, build_badge_group, build_toolbar + + +class BadgeDemoDirective(SphinxDirective): + """Insert a gallery of badge variants into the doctree.""" + + has_content = False + required_arguments = 0 + + def run(self) -> list[nodes.Node]: + """Build a gallery of every badge variant.""" + result: list[nodes.Node] = [] + + def _section(title: str) -> nodes.paragraph: + p = nodes.paragraph() + p += nodes.strong(text=title) + return p + + def _row(*badge_nodes: nodes.Node, label: str = "") -> nodes.paragraph: + p = nodes.paragraph() + for n in badge_nodes: + p += n + p += nodes.Text(" ") + if label: + p += nodes.literal(text=label) + return p + + result.append(_section("Filled (default)")) + result.append( + _row( + build_badge("label", tooltip="Default filled badge"), + label='build_badge("label")', + ) + ) + result.append( + _row( + build_badge( + "with icon", + icon="\U0001f50d", + tooltip="Badge with emoji icon", + ), + label='build_badge("with icon", icon="\\U0001f50d")', + ) + ) + + result.append(_section("Outline")) + result.append( + _row( + build_badge("outline", fill="outline", tooltip="Outline variant"), + label='build_badge("outline", fill="outline")', + ) + ) + + result.append(_section("Icon-only")) + result.append( + _row( + build_badge( + "", + style="icon-only", + icon="\U0001f50d", + tooltip="Icon-only badge", + ), + label='build_badge("", style="icon-only", icon="\\U0001f50d")', + ) + ) + + result.append(_section("Inline-icon (inside code chips)")) + code = nodes.literal(text="some_function()") + inline_icon = build_badge( + "", + style="inline-icon", + icon="\u270f\ufe0f", + tooltip="Inline icon", + tabindex="", + ) + wrapper = nodes.paragraph() + wrapper += inline_icon + wrapper += code + wrapper += nodes.Text(" ") + wrapper += nodes.literal( + text='build_badge("", style="inline-icon", icon="\\u270f\\ufe0f")' + ) + result.append(wrapper) + + result.append(_section("Badge group")) + group = build_badge_group( + [ + build_badge("alpha", tooltip="First"), + build_badge("beta", tooltip="Second"), + build_badge("gamma", tooltip="Third"), + ] + ) + result.append( + _row(group, label="build_badge_group([...badges...])") + ) + + result.append(_section("Toolbar (push-right in flex heading)")) + tb = build_toolbar( + build_badge_group( + [ + build_badge( + "readonly", + icon="\U0001f50d", + tooltip="Read-only", + ), + build_badge("tool", tooltip="MCP tool"), + ] + ) + ) + heading_container = nodes.container(classes=["sab-demo-toolbar-heading"]) + heading_p = nodes.paragraph() + heading_p += nodes.strong(text="Example heading ") + heading_p += tb + heading_container += heading_p + result.append(heading_container) + result.append( + _row(label="build_toolbar(build_badge_group([...]))") + ) + + return result + + +def setup(app: Sphinx) -> dict: + """Register the ``sab-badge-demo`` directive.""" + app.add_directive("sab-badge-demo", BadgeDemoDirective) + app.add_css_file("css/sab_demo.css") + return {"version": "0.1", "parallel_read_safe": True} diff --git a/docs/_static/css/sab_demo.css b/docs/_static/css/sab_demo.css new file mode 100644 index 0000000..6c78b18 --- /dev/null +++ b/docs/_static/css/sab_demo.css @@ -0,0 +1,9 @@ +.sab-demo-toolbar-heading > p { + display: flex; + align-items: center; + gap: 0.45rem; + background: var(--color-background-secondary); + border: 1px solid var(--color-background-border); + border-radius: 0.5rem; + padding: 0.5rem 1rem; +} diff --git a/docs/conf.py b/docs/conf.py index 72da2b0..7fb6cb2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,6 +19,10 @@ sys.path.insert(0, str(project_root / "packages" / "sphinx-autodoc-docutils" / "src")) sys.path.insert(0, str(project_root / "packages" / "sphinx-autodoc-sphinx" / "src")) sys.path.insert(0, str(project_root / "packages" / "sphinx-autodoc-api-style" / "src")) +sys.path.insert( + 0, + str(project_root / "packages" / "sphinx-autodoc-badges" / "src"), +) sys.path.insert(0, str(cwd / "_ext")) # docs demo modules import gp_sphinx # noqa: E402 @@ -38,6 +42,8 @@ source_branch="main", extra_extensions=[ "package_reference", + "sab_demo", + "sphinx_autodoc_badges", "sphinx_autodoc_api_style", "sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_docutils", diff --git a/docs/packages/sphinx-autodoc-badges.md b/docs/packages/sphinx-autodoc-badges.md index 0f48a3f..3cefd05 100644 --- a/docs/packages/sphinx-autodoc-badges.md +++ b/docs/packages/sphinx-autodoc-badges.md @@ -1,24 +1,237 @@ +(sphinx-autodoc-badges)= + # sphinx-autodoc-badges {bdg-warning-line}`Alpha` {bdg-link-secondary-line}`GitHub ` {bdg-link-secondary-line}`PyPI ` -Shared badge node and CSS infrastructure for Sphinx autodoc extensions. +Shared badge node, HTML visitors, and CSS infrastructure for Sphinx autodoc +extensions. Provides a single `BadgeNode` and builder API that +{doc}`sphinx-autodoc-api-style`, {doc}`sphinx-autodoc-pytest-fixtures`, and +{doc}`sphinx-autodoc-fastmcp` share instead of reimplementing badges +independently. + +```console +$ pip install sphinx-autodoc-badges +``` + +## How it works + +`setup()` registers the extension with Sphinx: + +1. {py:meth}`~sphinx.application.Sphinx.add_node` registers `BadgeNode` with + HTML visitors (`visit_badge_html` / `depart_badge_html`). +2. {py:meth}`~sphinx.application.Sphinx.add_css_file` injects the shared + `sphinx_autodoc_badges.css` stylesheet. +3. Downstream extensions call + {py:meth}`~sphinx.application.Sphinx.setup_extension` to load the badge + layer: + +```python +def setup(app: Sphinx) -> dict[str, Any]: + app.setup_extension("sphinx_autodoc_badges") +``` + +`BadgeNode` subclasses {py:class}`docutils.nodes.inline`, so unregistered +builders (text, LaTeX, man) fall back to `visit_inline` via Sphinx's +MRO-based dispatch — no special handling needed. + +## Live badge demos + +Every variant rendered by the real `build_badge` / `build_badge_group` / +`build_toolbar` API: + +```{sab-badge-demo} +``` + +## API reference + +```{eval-rst} +.. autofunction:: sphinx_autodoc_badges.build_badge + +.. autofunction:: sphinx_autodoc_badges.build_badge_group + +.. autofunction:: sphinx_autodoc_badges.build_toolbar + +.. autoclass:: sphinx_autodoc_badges.BadgeNode + :no-members: + + .. rubric:: Constructor parameters + + .. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Parameter + - Default + - Description + * - ``text`` + - ``""`` + - Visible label. Empty string for icon-only badges. + * - ``badge_tooltip`` + - ``""`` + - Hover text and ``aria-label``. + * - ``badge_icon`` + - ``""`` + - Emoji character rendered via CSS ``::before``. + * - ``badge_style`` + - ``"full"`` + - Structural variant: ``"full"``, ``"icon-only"``, ``"inline-icon"``. + * - ``tabindex`` + - ``"0"`` + - ``"0"`` for keyboard-focusable, ``""`` to skip. + * - ``classes`` + - ``None`` + - Additional CSS classes (plugin prefix + color class). + +.. autoclass:: sphinx_autodoc_badges._css.SAB + :members: + :undoc-members: + +.. autofunction:: sphinx_autodoc_badges.setup +``` + +## CSS custom properties + +All colors and metrics are exposed as CSS custom properties on `:root`. +Override them in your project's `custom.css` or via +{py:meth}`~sphinx.application.Sphinx.add_css_file`. + +### Defaults + +```css +:root { + /* ── Color hooks (set by downstream extensions) ────── */ + --sab-bg: transparent; /* badge background */ + --sab-fg: inherit; /* badge text color */ + --sab-border: none; /* badge border shorthand */ + + /* ── Metrics ───────────────────────────────────────── */ + --sab-font-size: 0.75em; + --sab-font-weight: 700; + --sab-padding-v: 0.35em; /* vertical padding */ + --sab-padding-h: 0.65em; /* horizontal padding */ + --sab-radius: 0.25rem; /* border-radius */ + --sab-icon-gap: 0.28rem; /* gap between icon and label */ + + /* ── Depth (inset shadow on solid badges) ──────────── */ + --sab-buff-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.2), + inset 0 -1px 2px rgba(0, 0, 0, 0.12); + --sab-buff-shadow-dark-ui: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 2px rgba(0, 0, 0, 0.28); +} +``` + +### Property reference + +```{list-table} +:header-rows: 1 +:widths: 30 70 + +* - Property + - Purpose +* - `--sab-bg` + - Badge background color. Extensions set this per badge class (e.g. green for "readonly"). +* - `--sab-fg` + - Badge text color. Falls back to `inherit` when unset. +* - `--sab-border` + - Border shorthand (`1px solid #...`). Defaults to `none`. +* - `--sab-font-size` + - Font size. Context-aware sizing (headings, body, TOC) overrides this. +* - `--sab-font-weight` + - Font weight. Default `700` (bold). +* - `--sab-padding-v` / `--sab-padding-h` + - Vertical and horizontal padding. +* - `--sab-radius` + - Border radius for pill shape. +* - `--sab-icon-gap` + - Gap between the `::before` icon and the label text. +* - `--sab-buff-shadow` + - Subtle inset highlight + shadow for depth on light backgrounds. +* - `--sab-buff-shadow-dark-ui` + - Stronger inset shadow variant for dark theme / `prefers-color-scheme: dark`. +``` + +## CSS class reference + +All classes use the `sab-` prefix (**s**phinx **a**utodoc **b**adges). + +```{list-table} +:header-rows: 1 +:widths: 25 15 60 + +* - Class + - Applied by + - Description +* - `sab-badge` + - `BadgeNode` + - Base class. Always present on every badge. +* - `sab-outline` + - `build_badge(fill="outline")` + - Transparent background, inherits text color. +* - `sab-icon-only` + - `build_badge(style="icon-only")` + - 16 × 16 colored box with emoji `::before`. +* - `sab-inline-icon` + - `build_badge(style="inline-icon")` + - Bare emoji inside a code chip, no background. +* - `sab-badge-group` + - `build_badge_group()` + - Flex container with `gap: 0.3rem` between badges. +* - `sab-toolbar` + - `build_toolbar()` + - Flex push-right (`margin-left: auto`) for title rows. +``` + +## Context-aware sizing + +Badge size adapts automatically based on where it appears in the document. +No extra classes needed — CSS selectors handle it. + +```{list-table} +:header-rows: 1 +:widths: 25 20 55 + +* - Context + - Font size + - Selectors +* - Heading (`h2`, `h3`) + - `0.68rem` + - `.body h2 .sab-badge`, `[role="main"] h3 .sab-badge` +* - Body (`p`, `li`, `td`, `a`) + - `0.62rem` + - `.body p .sab-badge`, `[role="main"] li .sab-badge`, etc. +* - TOC sidebar + - `0.58rem` + - `.toc-tree .sab-badge` (compact, with emoji icons) +``` + +## Downstream extensions + +Each extension adds its own CSS color layer on top of the shared base: -Provides `BadgeNode`, HTML visitors, and builder helpers that -`sphinx-autodoc-api-style`, `sphinx-autodoc-pytest-fixtures`, and -`sphinx-autodoc-fastmcp` share instead of reimplementing badges independently. +```{list-table} +:header-rows: 1 +:widths: 30 15 55 -## Features +* - Extension + - Prefix + - Badge types +* - {doc}`sphinx-autodoc-fastmcp` + - `smf-` + - Safety tiers (readonly / mutating / destructive), MCP tool type +* - {doc}`sphinx-autodoc-api-style` + - `gas-` + - Python object types (function, class, method, ...), modifiers (async, deprecated, ...) +* - {doc}`sphinx-autodoc-pytest-fixtures` + - `spf-` + - Fixture scopes (session, module, function), kind badges (autouse, yield) +``` -- **`BadgeNode(nodes.inline)`** -- MRO-safe custom node that falls back to - `visit_inline` in text/LaTeX/man builders -- **Shared CSS** -- base metrics, icon-only, inline-icon, TOC dot compression, - context-aware sizing, heading flex alignment -- **CSS custom properties** -- plugins set `--sab-bg` / `--sab-fg` / `--sab-border`; - projects override in `custom.css` for palette variants -- **Builder API** -- `build_badge()`, `build_badge_group()`, `build_toolbar()` +## Package reference -## Usage +```{package-reference} sphinx-autodoc-badges +``` -Extensions depend on this package and call `app.setup_extension("sphinx_autodoc_badges")` -in their `setup()` function. +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-badges) · [PyPI](https://pypi.org/project/sphinx-autodoc-badges/) From 904b633e2263a9d33fa1d0ac118b864f34c36d07 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 19:22:19 -0500 Subject: [PATCH 07/14] badges(feat[size]): Add xs/sm/lg/xl size variants why: Extensions and downstream docs need explicit size control that composes with fill, style, and color classes without being overridden by context selectors. what: - CSS: sab-xs/sab-sm/sab-lg/sab-xl classes; context rules wrapped in :where() - Python: badge_size param on BadgeNode, size param on build_badge() - SAB constants: XS, SM, LG, XL - Tests for size classes, invalid size ValueError, build_badge forwarding --- .../src/sphinx_autodoc_badges/_builders.py | 9 +++ .../src/sphinx_autodoc_badges/_css.py | 7 ++ .../src/sphinx_autodoc_badges/_nodes.py | 14 ++++ .../_static/css/sphinx_autodoc_badges.css | 74 ++++++++++++------- tests/ext/badges/test_badges.py | 23 ++++++ 5 files changed, 99 insertions(+), 28 deletions(-) diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py index bcf7917..b16e37b 100644 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_builders.py @@ -27,6 +27,7 @@ def build_badge( classes: t.Sequence[str] = (), style: str = "full", fill: str = "filled", + size: str = "", tabindex: str = "0", ) -> BadgeNode: """Build a single badge node. @@ -45,6 +46,9 @@ def build_badge( Structural variant: ``"full"``, ``"icon-only"``, ``"inline-icon"``. fill : str Visual fill: ``"filled"`` (default) or ``"outline"``. + size : str + Optional size tier: ``"xs"``, ``"sm"``, ``"lg"``, or ``"xl"``. + Empty string uses the default (no extra class). tabindex : str ``"0"`` for focusable, ``""`` to skip. @@ -61,6 +65,10 @@ def build_badge( >>> b = build_badge("", style="icon-only", classes=["smf-safety-readonly"]) >>> "sab-icon-only" in b["classes"] True + + >>> b = build_badge("big", size="lg") + >>> "sab-lg" in b["classes"] + True """ extra_classes = list(classes) if fill == "outline": @@ -70,6 +78,7 @@ def build_badge( badge_tooltip=tooltip, badge_icon=icon, badge_style=style, + badge_size=size, tabindex=tabindex, classes=extra_classes, ) diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py index e38281b..fb7d128 100644 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_css.py @@ -13,6 +13,9 @@ >>> SAB.ICON_ONLY 'sab-icon-only' + +>>> SAB.SM +'sab-sm' """ from __future__ import annotations @@ -38,3 +41,7 @@ class SAB: INLINE_ICON = f"{PREFIX}-inline-icon" OUTLINE = f"{PREFIX}-outline" FILLED = f"{PREFIX}-filled" + XS = f"{PREFIX}-xs" + SM = f"{PREFIX}-sm" + LG = f"{PREFIX}-lg" + XL = f"{PREFIX}-xl" diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py index 3b9e2ef..6214b3c 100644 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_nodes.py @@ -11,6 +11,10 @@ >>> "sab-badge" in node["classes"] True + +>>> n2 = BadgeNode("sm", badge_size="sm") +>>> "sab-sm" in n2["classes"] +True """ from __future__ import annotations @@ -19,6 +23,8 @@ from docutils import nodes +_BADGE_SIZES = frozenset({"xs", "sm", "lg", "xl"}) + class BadgeNode(nodes.inline): """Inline badge rendered as ```` with ARIA and icon support. @@ -44,6 +50,7 @@ def __init__( badge_tooltip: str = "", badge_icon: str = "", badge_style: str = "full", + badge_size: str = "", tabindex: str = "0", classes: list[str] | None = None, **attributes: t.Any, @@ -60,5 +67,12 @@ def __init__( if badge_style != "full": self["badge_style"] = badge_style self["classes"].append(f"sab-{badge_style}") + if badge_size: + if badge_size not in _BADGE_SIZES: + allowed = sorted(_BADGE_SIZES) + msg = f"badge_size must be one of {allowed!r}, got {badge_size!r}" + raise ValueError(msg) + self["badge_size"] = badge_size + self["classes"].append(f"sab-{badge_size}") if tabindex: self["tabindex"] = tabindex diff --git a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css index 46d5757..65727c1 100644 --- a/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css +++ b/packages/sphinx-autodoc-badges/src/sphinx_autodoc_badges/_static/css/sphinx_autodoc_badges.css @@ -38,6 +38,27 @@ box-sizing: border-box; } +/* ── Size variants (explicit; compose with outline / icon-only / color classes) ─ */ +.sab-badge.sab-xs { + font-size: 0.58em; + padding: 0.2em 0.42em; +} + +.sab-badge.sab-sm { + font-size: 0.65em; + padding: 0.28em 0.52em; +} + +.sab-badge.sab-lg { + font-size: 0.88em; + padding: 0.4em 0.75em; +} + +.sab-badge.sab-xl { + font-size: 1.05em; + padding: 0.45em 0.85em; +} + .sab-badge:not(.sab-outline):not(.sab-inline-icon) { box-shadow: var(--sab-buff-shadow); } @@ -160,55 +181,52 @@ code.docutils .sab-badge.sab-inline-icon:last-child { /* ── Context-aware badge sizing ─────────────────────────── * * Scoped to .document-content (Furo main body) to avoid * applying in sidebar, TOC, or navigation contexts. + * Ancestors use :where() so explicit .sab-xs–.sab-xl size classes win. */ -.body h2 .sab-badge, -.body h3 .sab-badge, -[role="main"] h2 .sab-badge, -[role="main"] h3 .sab-badge { +:where(.body h2, .body h3, [role="main"] h2, [role="main"] h3) .sab-badge { font-size: 0.68rem; padding: 0.17rem 0.4rem; } -.body p .sab-badge, -.body li .sab-badge, -.body td .sab-badge, -.body a .sab-badge, -[role="main"] p .sab-badge, -[role="main"] li .sab-badge, -[role="main"] td .sab-badge, -[role="main"] a .sab-badge { +:where( + .body p, + .body li, + .body td, + .body a, + [role="main"] p, + [role="main"] li, + [role="main"] td, + [role="main"] a + ) + .sab-badge { font-size: 0.62rem; padding: 0.12rem 0.32rem; } /* ── Consistent code → badge spacing (body only) ────────── */ -.body code.docutils + .sab-badge, -.body .sab-badge + code.docutils, -[role="main"] code.docutils + .sab-badge, -[role="main"] .sab-badge + code.docutils { +:where(.body) code.docutils + .sab-badge, +:where(.body) .sab-badge + code.docutils, +:where([role="main"]) code.docutils + .sab-badge, +:where([role="main"]) .sab-badge + code.docutils { margin-left: 0.4em; } /* ── Link behavior: underline code only, on hover ───────── */ -.body a.reference .sab-badge, -[role="main"] a.reference .sab-badge { +:where(.body, [role="main"]) a.reference .sab-badge { text-decoration: none; vertical-align: middle; } -.body a.reference:has(.sab-badge) code, -[role="main"] a.reference:has(.sab-badge) code { +:where(.body, [role="main"]) a.reference:has(.sab-badge) code { vertical-align: middle; text-decoration: none; } -.body a.reference:has(.sab-badge), -[role="main"] a.reference:has(.sab-badge) { +:where(.body, [role="main"]) a.reference:has(.sab-badge) { text-decoration: none; } -.body a.reference:has(.sab-badge):hover code, -[role="main"] a.reference:has(.sab-badge):hover code { +:where(.body, [role="main"]) a.reference:has(.sab-badge):hover code { text-decoration: underline; } @@ -217,8 +235,8 @@ code.docutils .sab-badge.sab-inline-icon:last-child { * Container wrappers collapse to inline flow. * Emoji icons shown at compact size (data-icon / ::before). */ -.toc-tree .sab-toolbar, -.toc-tree .sab-badge-group { +:where(.toc-tree) .sab-toolbar, +:where(.toc-tree) .sab-badge-group { display: inline; gap: 0; margin: 0; @@ -227,7 +245,7 @@ code.docutils .sab-badge.sab-inline-icon:last-child { background: none; } -.toc-tree .sab-badge { +:where(.toc-tree) .sab-badge { font-size: 0.58rem; padding: 0.1rem 0.25rem; gap: 0.06rem; @@ -235,7 +253,7 @@ code.docutils .sab-badge.sab-inline-icon:last-child { line-height: 1.1; } -.toc-tree .sab-badge::before { +:where(.toc-tree) .sab-badge::before { font-size: 0.5rem; margin-right: 0.08rem; flex-shrink: 0; diff --git a/tests/ext/badges/test_badges.py b/tests/ext/badges/test_badges.py index 99019c6..b793c7b 100644 --- a/tests/ext/badges/test_badges.py +++ b/tests/ext/badges/test_badges.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from docutils import nodes from sphinx_autodoc_badges import ( BadgeNode, @@ -97,6 +98,24 @@ def test_build_badge_outline() -> None: assert "gas-type-function" in b["classes"] +def test_badge_node_size() -> None: + """badge_size adds sab-{xs,sm,lg,xl} class.""" + assert SAB.LG in BadgeNode("x", badge_size="lg")["classes"] + + +def test_badge_node_invalid_size_raises() -> None: + """Invalid badge_size raises ValueError.""" + with pytest.raises(ValueError, match="badge_size"): + BadgeNode("x", badge_size="huge") + + +def test_build_badge_size() -> None: + """build_badge size= forwards to BadgeNode.""" + b = build_badge("label", size="sm") + assert SAB.SM in b["classes"] + assert b["badge_size"] == "sm" + + def test_build_badge_group() -> None: """build_badge_group wraps badges with spacing.""" b1 = build_badge("a") @@ -150,3 +169,7 @@ def test_css_constants() -> None: assert SAB.PREFIX == "sab" assert SAB.BADGE == "sab-badge" assert SAB.ICON_ONLY == "sab-icon-only" + assert SAB.XS == "sab-xs" + assert SAB.SM == "sab-sm" + assert SAB.LG == "sab-lg" + assert SAB.XL == "sab-xl" From 445e7e6c5e06959dd627e28e93fb4f7357165c7e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 19:22:25 -0500 Subject: [PATCH 08/14] docs(packages[badges]): Size variants demo and CSS class reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Document the new xs/sm/lg/xl size API alongside existing badge demos. what: - sab_demo.py: size variants row (xs through xl side-by-side) - sphinx-autodoc-badges.md: badge_size param, sab-xs–sab-xl in CSS table, note that explicit sizes override context selectors --- docs/_ext/sab_demo.py | 20 ++++++++++++++------ docs/packages/sphinx-autodoc-badges.md | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/_ext/sab_demo.py b/docs/_ext/sab_demo.py index 46ef580..2b4b650 100644 --- a/docs/_ext/sab_demo.py +++ b/docs/_ext/sab_demo.py @@ -37,6 +37,18 @@ def _row(*badge_nodes: nodes.Node, label: str = "") -> nodes.paragraph: p += nodes.literal(text=label) return p + result.append(_section("Size variants (xs / sm / default / lg / xl)")) + result.append( + _row( + build_badge("xs", size="xs", tooltip="Extra small"), + build_badge("sm", size="sm", tooltip="Small"), + build_badge("md", tooltip="Default (no size class)"), + build_badge("lg", size="lg", tooltip="Large"), + build_badge("xl", size="xl", tooltip="Extra large"), + label='build_badge("lg", size="lg")', + ) + ) + result.append(_section("Filled (default)")) result.append( _row( @@ -102,9 +114,7 @@ def _row(*badge_nodes: nodes.Node, label: str = "") -> nodes.paragraph: build_badge("gamma", tooltip="Third"), ] ) - result.append( - _row(group, label="build_badge_group([...badges...])") - ) + result.append(_row(group, label="build_badge_group([...badges...])")) result.append(_section("Toolbar (push-right in flex heading)")) tb = build_toolbar( @@ -125,9 +135,7 @@ def _row(*badge_nodes: nodes.Node, label: str = "") -> nodes.paragraph: heading_p += tb heading_container += heading_p result.append(heading_container) - result.append( - _row(label="build_toolbar(build_badge_group([...]))") - ) + result.append(_row(label="build_toolbar(build_badge_group([...]))")) return result diff --git a/docs/packages/sphinx-autodoc-badges.md b/docs/packages/sphinx-autodoc-badges.md index 3cefd05..2687bb0 100644 --- a/docs/packages/sphinx-autodoc-badges.md +++ b/docs/packages/sphinx-autodoc-badges.md @@ -76,6 +76,9 @@ Every variant rendered by the real `build_badge` / `build_badge_group` / * - ``badge_style`` - ``"full"`` - Structural variant: ``"full"``, ``"icon-only"``, ``"inline-icon"``. + * - ``badge_size`` + - ``""`` + - Optional size: ``"xs"``, ``"sm"``, ``"lg"``, or ``"xl"``. Empty means default. * - ``tabindex`` - ``"0"`` - ``"0"`` for keyboard-focusable, ``""`` to skip. @@ -182,12 +185,25 @@ All classes use the `sab-` prefix (**s**phinx **a**utodoc **b**adges). * - `sab-toolbar` - `build_toolbar()` - Flex push-right (`margin-left: auto`) for title rows. +* - `sab-xs` + - `build_badge(size="xs")` / `BadgeNode(..., badge_size="xs")` + - Extra small (dense tables, tight UI). +* - `sab-sm` + - `build_badge(size="sm")` + - Small inline badges. +* - `sab-lg` + - `build_badge(size="lg")` + - Large (section titles, callouts). +* - `sab-xl` + - `build_badge(size="xl")` + - Extra large (hero / landing emphasis). ``` ## Context-aware sizing Badge size adapts automatically based on where it appears in the document. -No extra classes needed — CSS selectors handle it. +CSS selectors handle it. Explicit size classes (`sab-xs` … `sab-xl`) override +contextual sizing when present (higher specificity than context rules). ```{list-table} :header-rows: 1 From edbd1bdcb248adbe84eb5eab12c52968bcbf369a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 19:28:12 -0500 Subject: [PATCH 09/14] api-style+fixtures(fix[css]): Replace dead abbr selectors with class-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: BadgeNode renders , not ; the abbr.gas-badge / abbr.spf-badge selectors no longer match, so border-color reinforcement was lost. what: - api_style.css: abbr.gas-* → .gas-*.gas-badge (element-agnostic) - sphinx_autodoc_pytest_fixtures.css: abbr.spf-* → .spf-*.spf-badge --- .../_static/css/api_style.css | 45 ++++++++----------- .../css/sphinx_autodoc_pytest_fixtures.css | 35 +++++---------- 2 files changed, 30 insertions(+), 50 deletions(-) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css index 86f8ce4..cd3721b 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css @@ -343,33 +343,26 @@ dl.py:not(.fixture) > dt .gas-badge-group { border-color: var(--gas-deprecated-border); } -/* ── abbr[title] specificity fix ─────────────────────────── - * Same fix as fixture extension: Normalize.css sets - * abbr[title] { border-bottom: none; text-decoration: underline dotted } - * which conflicts with badge border styling. +/* ── Border color reinforcement ──────────────────────────── + * BadgeNode renders ; legacy builds may still emit . + * Target both element-agnostically via class selectors. * ────────────────────────────────────────────────────────── */ -abbr.gas-badge { - border-bottom-style: solid; - border-bottom-width: var(--gas-badge-border-w, 1px); - text-decoration: underline dotted; -} - -abbr.gas-type-function { border-color: var(--gas-type-function-border); } -abbr.gas-type-class { border-color: var(--gas-type-class-border); } -abbr.gas-type-method { border-color: var(--gas-type-method-border); } -abbr.gas-type-classmethod { border-color: var(--gas-type-method-border); } -abbr.gas-type-staticmethod { border-color: var(--gas-type-method-border); } -abbr.gas-type-property { border-color: var(--gas-type-property-border); } -abbr.gas-type-attribute { border-color: var(--gas-type-attribute-border); } -abbr.gas-type-data { border-color: var(--gas-type-data-border); } -abbr.gas-type-exception { border-color: var(--gas-type-exception-border); } -abbr.gas-type-type { border-color: var(--gas-type-type-border); } -abbr.gas-mod-async { border-color: var(--gas-mod-async-border); } -abbr.gas-mod-classmethod { border-color: var(--gas-mod-classmethod-border); } -abbr.gas-mod-staticmethod { border-color: var(--gas-mod-staticmethod-border); } -abbr.gas-mod-abstract { border-color: var(--gas-mod-abstract-border); } -abbr.gas-mod-final { border-color: var(--gas-mod-final-border); } -abbr.gas-deprecated { border-color: var(--gas-deprecated-border); } +.gas-type-function.gas-badge { border-color: var(--gas-type-function-border); } +.gas-type-class.gas-badge { border-color: var(--gas-type-class-border); } +.gas-type-method.gas-badge { border-color: var(--gas-type-method-border); } +.gas-type-classmethod.gas-badge { border-color: var(--gas-type-method-border); } +.gas-type-staticmethod.gas-badge { border-color: var(--gas-type-method-border); } +.gas-type-property.gas-badge { border-color: var(--gas-type-property-border); } +.gas-type-attribute.gas-badge { border-color: var(--gas-type-attribute-border); } +.gas-type-data.gas-badge { border-color: var(--gas-type-data-border); } +.gas-type-exception.gas-badge { border-color: var(--gas-type-exception-border); } +.gas-type-type.gas-badge { border-color: var(--gas-type-type-border); } +.gas-mod-async.gas-badge { border-color: var(--gas-mod-async-border); } +.gas-mod-classmethod.gas-badge { border-color: var(--gas-mod-classmethod-border); } +.gas-mod-staticmethod.gas-badge { border-color: var(--gas-mod-staticmethod-border); } +.gas-mod-abstract.gas-badge { border-color: var(--gas-mod-abstract-border); } +.gas-mod-final.gas-badge { border-color: var(--gas-mod-final-border); } +.gas-deprecated.gas-badge { border-color: var(--gas-deprecated-border); } /* ── Deprecated entry muting ───────────────────────────── */ dl.py.gas-deprecated > dt { diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css index dbff821..f35ed88 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css @@ -273,31 +273,18 @@ dl.py.fixture > dt .spf-badge-group { border-color: var(--spf-deprecated-border); } -/* ── abbr[title] specificity fix ─────────────────────────────────────── - * Normalize.css (bundled with Furo) sets on abbr[title]: - * border-bottom: none — removes bottom border entirely - * text-decoration: underline dotted — adds unwanted dotted underline - * Specificity of abbr[title] is (0,1,1) which beats .spf-badge (0,1,0), - * so the bottom border is trimmed and the underline bleeds through. - * - * Fix: use abbr.spf-badge (0,1,1) to win on source order. - * The border-color reset by abbr[title] also needs per-variant overrides - * at the same specificity, otherwise the bottom border colour falls back - * to currentColor (the text fg colour) instead of the border variable. +/* ── Border color reinforcement ──────────────────────────── + * BadgeNode renders ; legacy builds may still emit . + * Target both element-agnostically via class selectors. * ─────────────────────────────────────────────────────────────────────── */ -abbr.spf-badge { - border-bottom-style: solid; - border-bottom-width: var(--spf-badge-border-w, 1px); - text-decoration: underline dotted; -} -abbr.spf-badge--fixture { border-color: var(--spf-fixture-border); } -abbr.spf-scope-session { border-color: var(--spf-scope-session-border); } -abbr.spf-scope-module { border-color: var(--spf-scope-module-border); } -abbr.spf-scope-class { border-color: var(--spf-scope-class-border); } -abbr.spf-factory { border-color: var(--spf-kind-factory-border); } -abbr.spf-override { border-color: var(--spf-kind-override-border); } -abbr.spf-autouse { border-color: var(--spf-state-autouse-border); } -abbr.spf-deprecated { border-color: var(--spf-deprecated-border); } +.spf-badge--fixture.spf-badge { border-color: var(--spf-fixture-border); } +.spf-scope-session.spf-badge { border-color: var(--spf-scope-session-border); } +.spf-scope-module.spf-badge { border-color: var(--spf-scope-module-border); } +.spf-scope-class.spf-badge { border-color: var(--spf-scope-class-border); } +.spf-factory.spf-badge { border-color: var(--spf-kind-factory-border); } +.spf-override.spf-badge { border-color: var(--spf-kind-override-border); } +.spf-autouse.spf-badge { border-color: var(--spf-state-autouse-border); } +.spf-deprecated.spf-badge { border-color: var(--spf-deprecated-border); } dl.py.fixture.spf-deprecated > dt { opacity: 0.7; From bb36d89fc136ffe42f5a85f8f51aed475fccd9d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 19:35:59 -0500 Subject: [PATCH 10/14] api-style+fixtures(fix[badges]): Drop fill="outline" that killed backgrounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: sab-outline sets background:transparent at (0,2,0) specificity, which overrides gas-type-*/spf-* background-color at (0,1,0). Production badges are filled — the extension CSS provides the backgrounds, not sab. what: - api-style: type badges use default fill (filled); modifiers keep outline - pytest-fixtures: all badges use default fill (CSS handles backgrounds) --- .../src/sphinx_autodoc_api_style/_badges.py | 1 - .../src/sphinx_autodoc_pytest_fixtures/_badges.py | 6 ------ 2 files changed, 7 deletions(-) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py index 5cc1bed..43a0649 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_badges.py @@ -151,7 +151,6 @@ def build_badge_group( label, tooltip=tooltip, classes=[_CSS.BADGE, _CSS.BADGE_TYPE, _CSS.obj_type(objtype)], - fill="outline", ), ) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py index 8c94bb6..ae90c2f 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py @@ -65,7 +65,6 @@ def _build_badge_group_node( "deprecated", tooltip=_BADGE_TOOLTIPS["deprecated"], classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.DEPRECATED], - fill="outline", ) ) @@ -75,7 +74,6 @@ def _build_badge_group_node( scope, tooltip=_BADGE_TOOLTIPS.get(scope, f"Scope: {scope}"), classes=[_CSS.BADGE, _CSS.BADGE_SCOPE, _CSS.scope(scope)], - fill="outline", ) ) @@ -85,7 +83,6 @@ def _build_badge_group_node( "auto", tooltip=_BADGE_TOOLTIPS["autouse"], classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.AUTOUSE], - fill="outline", ) ) elif kind == "factory": @@ -94,7 +91,6 @@ def _build_badge_group_node( "factory", tooltip=_BADGE_TOOLTIPS["factory"], classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.FACTORY], - fill="outline", ) ) elif kind == "override_hook": @@ -103,7 +99,6 @@ def _build_badge_group_node( "override", tooltip=_BADGE_TOOLTIPS["override_hook"], classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.OVERRIDE], - fill="outline", ) ) @@ -113,7 +108,6 @@ def _build_badge_group_node( "fixture", tooltip=_BADGE_TOOLTIPS["fixture"], classes=[_CSS.BADGE, _CSS.BADGE_FIXTURE], - fill="outline", ) ) From 26342b77cd4d1f37965961e60a20a12e58bcbae3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 19:38:23 -0500 Subject: [PATCH 11/14] api-style(fix[css]): Restore dotted underline on gas-badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The abbr→class migration dropped text-decoration: underline dotted that was previously on abbr.gas-badge. what: - Add text-decoration: underline dotted to .gas-badge base rule --- .../src/sphinx_autodoc_api_style/_static/css/api_style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css index cd3721b..f397575 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_static/css/api_style.css @@ -230,6 +230,7 @@ dl.py:not(.fixture) > dt .gas-badge-group { border-radius: 0.22rem; border: var(--gas-badge-border-w, 1px) solid; vertical-align: middle; + text-decoration: underline dotted; } /* Touch/keyboard tooltip */ From f58b90858dcd29bb253b020f3c6bac8fb5f3648c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 19:42:38 -0500 Subject: [PATCH 12/14] docs(_ext[sab_demo]): Fix mypy dict type-arg on setup() why: CI runs mypy . (whole workspace) but local ran mypy src tests; dict return annotation needs generic args to pass strict type-arg check. what: - Add import typing as t - Annotate setup() return type as dict[str, t.Any] --- docs/_ext/sab_demo.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/_ext/sab_demo.py b/docs/_ext/sab_demo.py index 2b4b650..0a010d2 100644 --- a/docs/_ext/sab_demo.py +++ b/docs/_ext/sab_demo.py @@ -7,6 +7,8 @@ from __future__ import annotations +import typing as t + from docutils import nodes from sphinx.application import Sphinx from sphinx.util.docutils import SphinxDirective @@ -140,7 +142,7 @@ def _row(*badge_nodes: nodes.Node, label: str = "") -> nodes.paragraph: return result -def setup(app: Sphinx) -> dict: +def setup(app: Sphinx) -> dict[str, t.Any]: """Register the ``sab-badge-demo`` directive.""" app.add_directive("sab-badge-demo", BadgeDemoDirective) app.add_css_file("css/sab_demo.css") From f793d8ae5fab81643c508ffc0af7d13bce0dbb6c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 19:45:23 -0500 Subject: [PATCH 13/14] docs(CHANGES) sphinx-autodoc-badges shared layer, size variants, badge fix why: Record the badges infrastructure, size API, and rendering regression fix. what: - Features: sphinx-autodoc-badges package, xs/sm/lg/xl size variants - Bug fixes: badge colors/borders/underline after BadgeNode migration - Workspace packages: sphinx-autodoc-badges entry --- CHANGES | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGES b/CHANGES index 2fe1c01..c7ba4e8 100644 --- a/CHANGES +++ b/CHANGES @@ -22,6 +22,13 @@ $ uv add gp-sphinx --prerelease allow - `sphinx-autodoc-fastmcp`: new Sphinx extension for FastMCP tool docs (card-style `desc` layouts, safety badges, MyST directives, cross-reference roles) +- `sphinx-autodoc-badges`: shared badge node (`BadgeNode`), builder API + (`build_badge`, `build_badge_group`, `build_toolbar`), and base CSS + layer shared by `sphinx-autodoc-fastmcp`, `sphinx-autodoc-api-style`, + and `sphinx-autodoc-pytest-fixtures` (#13) +- `sphinx-autodoc-badges`: explicit size variants `xs` / `sm` / `lg` / `xl` via + `build_badge(size=...)` and `BadgeNode(badge_size=...)` — compose with any + fill, style, or color class (#13) - Initial release of `gp_sphinx` shared documentation platform - `merge_sphinx_config()` API for building complete Sphinx config from shared defaults - Shared extension list, theme options, MyST config, font config @@ -40,9 +47,17 @@ $ uv add gp-sphinx --prerelease allow `sphinx-autodoc-api-style`, `sphinx-autodoc-pytest-fixtures`, `sphinx-gptheme`, and docs (650 is not a standard Fontsource weight, so browsers were synthesizing bold instead of using the real font file) +- Badge background colors, border colors, and dotted-underline tooltips lost + after `BadgeNode` (``) replaced `` in `sphinx-autodoc-api-style` + and `sphinx-autodoc-pytest-fixtures`; restored via element-agnostic CSS + selectors and correct fill defaults (#13) ### Workspace packages +- `sphinx-autodoc-badges` — Shared badge node, builders, and base CSS for + safety tiers, scope, and kind labels. Extensions add color layers on top; + TOC sidebar shows compact badges with emoji icons and subtle inset depth on + solid pills (#13) - `sphinx-autodoc-pytest-fixtures` — Sphinx autodocumenter for pytest fixtures. Registers `py:fixture` as a domain object type with `autofixture::` for single-fixture docs, `autofixtures::` for bulk module discovery, and From 1b087d25f43e616ac366b45e3f6293e18905d6ad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 6 Apr 2026 20:19:37 -0500 Subject: [PATCH 14/14] api-style(feat[transforms]): prune empty desc_content after badge injection why: Undocumented autodoc objects left empty desc_content, producing blank
rows and extra vertical space in API cards. what: - Add _prune_empty_desc_content with doctest - Invoke it from on_doctree_resolved for handled py desc nodes - Add unit tests for direct prune, nonempty preserve, and pipeline --- .../sphinx_autodoc_api_style/_transforms.py | 27 ++++++++++ tests/ext/api_style/test_api_style.py | 50 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py index ec75272..9a44fc7 100644 --- a/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py +++ b/packages/sphinx-autodoc-api-style/src/sphinx_autodoc_api_style/_transforms.py @@ -188,6 +188,32 @@ def _inject_badges(sig_node: addnodes.desc_signature, objtype: str) -> None: sig_node += toolbar +def _prune_empty_desc_content(desc_node: addnodes.desc) -> None: + """Remove empty desc_content nodes from a desc tree. + + Sphinx always appends a desc_content child even when the object has + no docstring. An empty
wastes vertical space and creates + layout noise. Remove it so the CSS card only shows the signature row. + + Parameters + ---------- + desc_node : addnodes.desc + The description node to inspect. + + Examples + -------- + >>> from sphinx import addnodes + >>> desc = addnodes.desc() + >>> desc += addnodes.desc_content() # empty + >>> _prune_empty_desc_content(desc) + >>> any(isinstance(c, addnodes.desc_content) for c in desc.children) + False + """ + for child in list(desc_node.children): + if isinstance(child, addnodes.desc_content) and not child.children: + desc_node.remove(child) + + def on_doctree_resolved( app: Sphinx, doctree: nodes.document, @@ -230,3 +256,4 @@ def on_doctree_resolved( for child in desc_node.children: if isinstance(child, addnodes.desc_signature): _inject_badges(child, objtype) + _prune_empty_desc_content(desc_node) diff --git a/tests/ext/api_style/test_api_style.py b/tests/ext/api_style/test_api_style.py index c2adbf0..c2191f3 100644 --- a/tests/ext/api_style/test_api_style.py +++ b/tests/ext/api_style/test_api_style.py @@ -23,6 +23,7 @@ _detect_deprecated, _detect_modifiers, _inject_badges, + _prune_empty_desc_content, on_doctree_resolved, ) @@ -413,6 +414,55 @@ def test_inject_badges_headerlink_not_in_toolbar() -> None: assert len(sig_direct_refs) == 1, "headerlink should remain a direct child of sig" +# --------------------------------------------------------------------------- +# _prune_empty_desc_content +# --------------------------------------------------------------------------- + + +def test_prune_empty_desc_content_removes_empty() -> None: + """Empty desc_content is removed from the desc node.""" + desc = addnodes.desc() + desc += addnodes.desc_signature() + desc += addnodes.desc_content() # empty — no children + + _prune_empty_desc_content(desc) + + assert not any(isinstance(c, addnodes.desc_content) for c in desc.children) + + +def test_prune_empty_desc_content_keeps_nonempty() -> None: + """desc_content with children is not removed.""" + desc = addnodes.desc() + content = addnodes.desc_content() + content += nodes.paragraph("", "Has content.") + desc += addnodes.desc_signature() + desc += content + + _prune_empty_desc_content(desc) + + assert any(isinstance(c, addnodes.desc_content) for c in desc.children) + + +def test_on_doctree_resolved_prunes_empty_desc_content() -> None: + """on_doctree_resolved removes empty desc_content via full pipeline.""" + from unittest.mock import MagicMock + + app = MagicMock() + doc = nodes.document(None, None) # type: ignore[arg-type] + desc = addnodes.desc() + desc["domain"] = "py" + desc["objtype"] = "attribute" + sig = addnodes.desc_signature() + sig += addnodes.desc_name("", "session_id") + desc += sig + desc += addnodes.desc_content() # empty — simulates undocumented attribute + + doc += desc + on_doctree_resolved(app, doc, "index") + + assert not any(isinstance(c, addnodes.desc_content) for c in desc.children) + + # --------------------------------------------------------------------------- # on_doctree_resolved # ---------------------------------------------------------------------------