From 57818884bf404efec5bd54b2796288edd7b6ea84 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 09:43:56 -0500 Subject: [PATCH 01/33] gp-sphinx(autodoc-docutils[domain]): Add docutils cross-reference domain why: Issue #52 extends component autodoc to transforms, readers, parsers, writers, nodes, and translators. Those classes have no existing Sphinx domain objtype, so cross-references and anchors need a home before the per-type autodoc directives can land. what: - Add DocutilsDomain (name="docutils") with six object types, an XRefRole per objtype, and a grouped-by-objtype component index, following the ArgparseDomain lifecycle shape (note/clear/merge/ resolve/get_objects) for parallel-safe builds - Add DocutilsComponentDescription, a generic ObjectDescription whose signature is a dotted Python path (module as desc_addname, class as desc_name); add_target_and_index owns the anchor and notes the component into the domain - Lookup accepts fully-qualified paths and unambiguous bare class names - Register the domain in setup(); stub add_domain in the shared-stack setup test app --- .../src/sphinx_autodoc_docutils/__init__.py | 11 + .../src/sphinx_autodoc_docutils/domain.py | 396 ++++++++++++++++++ tests/ext/autodoc_docutils/test_domain.py | 287 +++++++++++++ tests/ext/test_shared_stack_setup.py | 1 + 4 files changed, 695 insertions(+) create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/domain.py create mode 100644 tests/ext/autodoc_docutils/test_domain.py diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 70c93a11..c3c76211 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -14,12 +14,18 @@ SetupRecorder, replay_setup, ) +from sphinx_autodoc_docutils.domain import ( + DocutilsComponentIndex, + DocutilsDomain, +) __all__ = [ "AutoDirective", "AutoDirectives", "AutoRole", "AutoRoles", + "DocutilsComponentIndex", + "DocutilsDomain", "SetupRecorder", "replay_setup", "setup", @@ -44,6 +50,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: ... self.calls.append(("setup_extension", name)) ... def add_directive(self, name: str, directive: object) -> None: ... self.calls.append(("add_directive", name)) + ... def add_domain(self, domain: object) -> None: + ... self.calls.append(("add_domain", domain)) ... def connect(self, event: str, handler: object) -> None: ... self.calls.append(("connect", event)) ... def add_css_file(self, filename: str) -> None: @@ -52,6 +60,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_directive", "autodirective") in fake.calls True + >>> ("add_domain", DocutilsDomain) in fake.calls + True >>> ("setup_extension", "sphinx_ux_autodoc_layout") in fake.calls True >>> ("add_css_file", "css/sphinx_autodoc_docutils.css") in fake.calls @@ -62,6 +72,7 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.setup_extension("sphinx_ux_badges") app.setup_extension("sphinx_ux_autodoc_layout") app.setup_extension("sphinx_autodoc_typehints_gp") + app.add_domain(DocutilsDomain) app.add_directive("autodirective", AutoDirective) app.add_directive("autodirectives", AutoDirectives) app.add_directive("autorole", AutoRole) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/domain.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/domain.py new file mode 100644 index 00000000..c189529c --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/domain.py @@ -0,0 +1,396 @@ +"""The docutils components Sphinx domain. + +Registers six object types (``transform``, ``reader``, ``parser``, +``writer``, ``node``, ``translator``) with matching cross-reference +roles, one grouped-by-objtype index, and the standard lifecycle hooks +Sphinx expects from a parallel-safe domain. + +The component autodoc directives wire into this domain by generating +``.. docutils::: dotted.path.ClassName`` markup, so the parsed +``desc`` nodes natively carry ``domain="docutils"`` and a per-type +``objtype`` — the shared layout and badge pipelines key off both. + +Examples +-------- +>>> from sphinx_autodoc_docutils.domain import DocutilsDomain +>>> DocutilsDomain.name +'docutils' +>>> sorted(DocutilsDomain.object_types) +['node', 'parser', 'reader', 'transform', 'translator', 'writer'] +>>> sorted(DocutilsDomain.roles) == sorted(DocutilsDomain.object_types) +True +>>> [cls.name for cls in DocutilsDomain.indices] +['componentindex'] +""" + +from __future__ import annotations + +import typing as t + +from docutils.parsers.rst import directives +from sphinx import addnodes +from sphinx.directives import ObjectDescription +from sphinx.domains import Domain, Index, IndexEntry, ObjType +from sphinx.locale import _ +from sphinx.roles import XRefRole +from sphinx.util.nodes import make_id, make_refnode + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Set + + from docutils import nodes + from docutils.nodes import Element + from sphinx.addnodes import pending_xref + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + from sphinx.util.typing import OptionSpec + + +#: Object type name used for docutils transforms. +TRANSFORM = "transform" +#: Object type name used for docutils readers. +READER = "reader" +#: Object type name used for docutils parsers. +PARSER = "parser" +#: Object type name used for docutils writers. +WRITER = "writer" +#: Object type name used for custom docutils node classes. +NODE = "node" +#: Object type name used for docutils translator classes. +TRANSLATOR = "translator" + +#: All object type names in a single tuple for iteration. +OBJECT_TYPES: tuple[str, ...] = ( + TRANSFORM, + READER, + PARSER, + WRITER, + NODE, + TRANSLATOR, +) + +#: Index group headings keyed by object type. +_INDEX_HEADINGS: dict[str, str] = { + TRANSFORM: "Transforms", + READER: "Readers", + PARSER: "Parsers", + WRITER: "Writers", + NODE: "Nodes", + TRANSLATOR: "Translators", +} + + +def split_component_path(path: str) -> tuple[str, str]: + """Split a dotted component path into ``(module, class_name)``. + + Examples + -------- + >>> split_component_path("docutils.transforms.misc.Transitions") + ('docutils.transforms.misc', 'Transitions') + + >>> split_component_path("Transitions") + ('', 'Transitions') + """ + module_name, _sep, class_name = path.rpartition(".") + return module_name, class_name + + +class DocutilsComponentDescription(ObjectDescription[str]): + """Object description for one docutils component class. + + The signature argument is a dotted Python path + (``pkg.module.ClassName``); the module prefix renders as + ``desc_addname`` and the class name as ``desc_name``, matching the + ``py:class`` visual structure. The anchor and cross-reference + target are owned by :class:`DocutilsDomain`. + """ + + option_spec: t.ClassVar[OptionSpec] = { + "no-index": directives.flag, + } + + def handle_signature( + self, + sig: str, + sig_node: addnodes.desc_signature, + ) -> str: + """Render *sig* (a dotted path) into the signature node.""" + path = sig.strip() + module_name, class_name = split_component_path(path) + if module_name: + sig_node += addnodes.desc_addname( + f"{module_name}.", + f"{module_name}.", + ) + sig_node += addnodes.desc_name(class_name, class_name) + sig_node["fullname"] = path + return path + + def _object_hierarchy_parts( + self, + sig_node: addnodes.desc_signature, + ) -> tuple[str, ...]: + """Return the TOC hierarchy parts for *sig_node*.""" + return (str(sig_node["fullname"]),) + + def _toc_entry_name(self, sig_node: addnodes.desc_signature) -> str: + """Return the local-TOC entry text (the bare class name).""" + if not sig_node.get("_toc_parts"): + return "" + (name,) = sig_node["_toc_parts"] + return split_component_path(str(name))[1] + + def add_target_and_index( + self, + name: str, + sig: str, + signode: addnodes.desc_signature, + ) -> None: + """Create the anchor and note the component in the domain.""" + node_id = make_id( + self.env, + self.state.document, + f"docutils-{self.objtype}", + name, + ) + signode["ids"].append(node_id) + self.state.document.note_explicit_target(signode) + domain = t.cast("DocutilsDomain", self.env.domains[DocutilsDomain.name]) + domain.note_component(self.objtype, name, self.env.docname, node_id) + + +class DocutilsComponentIndex(Index): + """Grouped-by-objtype index of every registered docutils component. + + The generated page lives at ``docutils-componentindex.html`` and can + be linked via ``:ref:`docutils-componentindex```. + + Examples + -------- + >>> DocutilsComponentIndex.name + 'componentindex' + >>> str(DocutilsComponentIndex.localname) + 'Docutils components index' + """ + + name = "componentindex" + localname = _("Docutils components index") + shortname = _("components") + + def generate( + self, + docnames: Iterable[str] | None = None, + ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: + """Build the component index entries grouped by object type.""" + content: dict[str, list[IndexEntry]] = {} + allowed = set(docnames) if docnames is not None else None + + for objtype in OBJECT_TYPES: + table: dict[str, tuple[str, str]] = self.domain.data.get(objtype, {}) + for name in sorted(table): + docname, anchor = table[name] + if allowed is not None and docname not in allowed: + continue + heading = _INDEX_HEADINGS[objtype] + content.setdefault(heading, []).append( + IndexEntry( + name=name, + subtype=0, + docname=docname, + anchor=anchor, + extra="", + qualifier="", + descr=_(objtype), + ), + ) + + return ( + sorted(content.items()), + True, + ) + + +class DocutilsDomain(Domain): + """Sphinx domain for docutils component documentation. + + Stores one dictionary per object type under + ``env.domaindata["docutils"]``:: + + data[objtype][qualified_name] = (docname, anchor) + + Components are keyed by their fully-qualified dotted Python path + (``"pkg.module.ClassName"``), which is unique per class. Lookup + additionally accepts the bare class name when it matches exactly + one registered component. + + Examples + -------- + >>> DocutilsDomain.name + 'docutils' + >>> DocutilsDomain.data_version + 0 + """ + + name = "docutils" + label = "Docutils" + + object_types = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + TRANSFORM: ObjType(_("transform"), TRANSFORM), + READER: ObjType(_("reader"), READER), + PARSER: ObjType(_("parser"), PARSER), + WRITER: ObjType(_("writer"), WRITER), + NODE: ObjType(_("node"), NODE), + TRANSLATOR: ObjType(_("translator"), TRANSLATOR), + } + + directives = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + TRANSFORM: DocutilsComponentDescription, + READER: DocutilsComponentDescription, + PARSER: DocutilsComponentDescription, + WRITER: DocutilsComponentDescription, + NODE: DocutilsComponentDescription, + TRANSLATOR: DocutilsComponentDescription, + } + + roles = { # noqa: RUF012 — XRefRole instances are safe to share across domains + TRANSFORM: XRefRole(), + READER: XRefRole(), + PARSER: XRefRole(), + WRITER: XRefRole(), + NODE: XRefRole(), + TRANSLATOR: XRefRole(), + } + + indices = [ # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + DocutilsComponentIndex, + ] + + initial_data = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + TRANSFORM: {}, + READER: {}, + PARSER: {}, + WRITER: {}, + NODE: {}, + TRANSLATOR: {}, + } + + data_version = 0 + + def components(self, objtype: str) -> dict[str, tuple[str, str]]: + """Return *objtype*'s table: ``qualified_name -> (docname, anchor)``.""" + return t.cast( + "dict[str, tuple[str, str]]", + self.data.setdefault(objtype, {}), + ) + + def note_component( + self, + objtype: str, + name: str, + docname: str, + anchor: str, + ) -> None: + """Record a component target in the domain data.""" + self.components(objtype)[name] = (docname, anchor) + + def clear_doc(self, docname: str) -> None: + """Drop every entry that came from *docname* so it can be re-built.""" + for objtype in OBJECT_TYPES: + table = self.components(objtype) + for name, (existing, _anchor) in list(table.items()): + if existing == docname: + del table[name] + + def merge_domaindata( + self, + docnames: Set[str], + otherdata: dict[str, t.Any], + ) -> None: + """Merge sibling worker's ``domaindata`` under parallel builds.""" + for objtype in OBJECT_TYPES: + for name, (docname, anchor) in otherdata.get(objtype, {}).items(): + if docname in docnames: + self.components(objtype)[name] = (docname, anchor) + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: Element, + ) -> nodes.reference | None: + """Resolve a single typed cross-reference to a docutils reference.""" + match = self._lookup(typ, target) + if match is None: + return None + todocname, anchor = match + return make_refnode( + builder, + fromdocname, + todocname, + anchor, + contnode, + target, + ) + + def resolve_any_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + target: str, + node: pending_xref, + contnode: Element, + ) -> list[tuple[str, nodes.reference]]: + """Resolve an untyped ``:any:`` cross-reference across object types.""" + results: list[tuple[str, nodes.reference]] = [] + for objtype in OBJECT_TYPES: + match = self._lookup(objtype, target) + if match is None: + continue + todocname, anchor = match + results.append( + ( + f"docutils:{objtype}", + make_refnode( + builder, + fromdocname, + todocname, + anchor, + contnode, + target, + ), + ), + ) + return results + + def get_objects(self) -> Iterator[tuple[str, str, str, str, str, int]]: + """Yield ``(name, dispname, type, docname, anchor, priority)`` rows.""" + for objtype in OBJECT_TYPES: + for name, (docname, anchor) in self.components(objtype).items(): + yield name, name, objtype, docname, anchor, 1 + + def _lookup(self, typ: str, target: str) -> tuple[str, str] | None: + """Look up *target* in *typ*'s table, accepting bare class names. + + Components are stored under fully-qualified dotted paths. + Authors commonly write the bare class name + (``SanitizeTransform``); fall back to a suffix match when it + identifies exactly one component. + """ + if typ not in OBJECT_TYPES: + return None + table = self.components(typ) + if target in table: + return table[target] + candidates = [ + value + for name, value in table.items() + if split_component_path(name)[1] == target + ] + if len(candidates) == 1: + return candidates[0] + return None diff --git a/tests/ext/autodoc_docutils/test_domain.py b/tests/ext/autodoc_docutils/test_domain.py new file mode 100644 index 00000000..4b19e745 --- /dev/null +++ b/tests/ext/autodoc_docutils/test_domain.py @@ -0,0 +1,287 @@ +"""Unit tests for DocutilsDomain. + +Constructs DocutilsDomain against a lightweight stub env and exercises +the note / clear / merge / resolve lifecycle plus the grouped component +index. Cross-reference resolution against a real Sphinx build lives in +``test_domain_xref_integration.py``. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from sphinx_autodoc_docutils.domain import ( + NODE, + OBJECT_TYPES, + PARSER, + READER, + TRANSFORM, + TRANSLATOR, + WRITER, + DocutilsComponentIndex, + DocutilsDomain, + split_component_path, +) + + +class _StubEnv: + """Minimal stand-in for ``BuildEnvironment`` — just ``domaindata``.""" + + def __init__(self) -> None: + self.domaindata: dict[str, dict[str, t.Any]] = {} + + +def _make_domain() -> DocutilsDomain: + """Build a DocutilsDomain bound to a fresh stub environment.""" + return DocutilsDomain(t.cast("t.Any", _StubEnv())) + + +def test_object_types_constants_match_domain() -> None: + """Module-level objtype names match the domain's registered keys.""" + assert set(OBJECT_TYPES) == { + TRANSFORM, + READER, + PARSER, + WRITER, + NODE, + TRANSLATOR, + } + assert set(DocutilsDomain.object_types) == set(OBJECT_TYPES) + assert set(DocutilsDomain.roles) == set(OBJECT_TYPES) + assert set(DocutilsDomain.directives) == set(OBJECT_TYPES) + + +def test_initial_data_contains_empty_tables() -> None: + """Fresh domain starts with one empty table per object type.""" + domain = _make_domain() + for objtype in OBJECT_TYPES: + assert domain.components(objtype) == {} + + +class SplitPathCase(t.NamedTuple): + """Test case for split_component_path().""" + + test_id: str + path: str + expected_module: str + expected_class: str + + +_SPLIT_PATH_CASES: list[SplitPathCase] = [ + SplitPathCase( + test_id="dotted_path", + path="pkg.transforms.SanitizeTransform", + expected_module="pkg.transforms", + expected_class="SanitizeTransform", + ), + SplitPathCase( + test_id="single_segment", + path="icon", + expected_module="", + expected_class="icon", + ), + SplitPathCase( + test_id="deeply_nested", + path="a.b.c.d.Writer", + expected_module="a.b.c.d", + expected_class="Writer", + ), +] + + +@pytest.mark.parametrize( + "case", + _SPLIT_PATH_CASES, + ids=lambda c: c.test_id, +) +def test_split_component_path(case: SplitPathCase) -> None: + """split_component_path divides on the final dot.""" + assert split_component_path(case.path) == ( + case.expected_module, + case.expected_class, + ) + + +class NoteComponentCase(t.NamedTuple): + """Test case for DocutilsDomain.note_component() per objtype.""" + + test_id: str + objtype: str + name: str + docname: str + anchor: str + + +_NOTE_COMPONENT_CASES: list[NoteComponentCase] = [ + NoteComponentCase( + test_id="transform", + objtype=TRANSFORM, + name="pkg.transforms.SanitizeTransform", + docname="api", + anchor="docutils-transform-pkg-transforms-sanitizetransform", + ), + NoteComponentCase( + test_id="reader", + objtype=READER, + name="pkg.readers.DemoReader", + docname="api", + anchor="docutils-reader-pkg-readers-demoreader", + ), + NoteComponentCase( + test_id="parser", + objtype=PARSER, + name="pkg.parsers.DemoParser", + docname="api", + anchor="docutils-parser-pkg-parsers-demoparser", + ), + NoteComponentCase( + test_id="writer", + objtype=WRITER, + name="pkg.writers.DemoWriter", + docname="api", + anchor="docutils-writer-pkg-writers-demowriter", + ), + NoteComponentCase( + test_id="node", + objtype=NODE, + name="pkg.nodes.icon", + docname="api", + anchor="docutils-node-pkg-nodes-icon", + ), + NoteComponentCase( + test_id="translator", + objtype=TRANSLATOR, + name="pkg.writers.DemoTranslator", + docname="api", + anchor="docutils-translator-pkg-writers-demotranslator", + ), +] + + +@pytest.mark.parametrize( + "case", + _NOTE_COMPONENT_CASES, + ids=lambda c: c.test_id, +) +def test_note_component_records_docname_and_anchor(case: NoteComponentCase) -> None: + """note_component stores (docname, anchor) under the qualified name.""" + domain = _make_domain() + domain.note_component(case.objtype, case.name, case.docname, case.anchor) + assert domain.components(case.objtype) == { + case.name: (case.docname, case.anchor), + } + for other in OBJECT_TYPES: + if other != case.objtype: + assert domain.components(other) == {} + + +def test_clear_doc_removes_only_matching_docname() -> None: + """clear_doc drops entries from *docname* and keeps the rest.""" + domain = _make_domain() + domain.note_component(TRANSFORM, "pkg.A", "page-a", "anchor-a") + domain.note_component(TRANSFORM, "pkg.B", "page-b", "anchor-b") + domain.note_component(WRITER, "pkg.W", "page-a", "anchor-w") + + domain.clear_doc("page-a") + + assert domain.components(TRANSFORM) == {"pkg.B": ("page-b", "anchor-b")} + assert domain.components(WRITER) == {} + + +def test_merge_domaindata_merges_entries_within_docnames() -> None: + """Parallel-worker merge retains entries for docnames in the active set.""" + domain = _make_domain() + other: dict[str, t.Any] = { + TRANSFORM: {"pkg.Sibling": ("pageB", "anchor-sibling")}, + NODE: {"pkg.icon": ("pageB", "anchor-icon")}, + } + domain.merge_domaindata({"pageB"}, other) + assert domain.components(TRANSFORM) == {"pkg.Sibling": ("pageB", "anchor-sibling")} + assert domain.components(NODE) == {"pkg.icon": ("pageB", "anchor-icon")} + + +def test_merge_domaindata_ignores_entries_outside_docnames() -> None: + """Entries whose docname is NOT in *docnames* are dropped on merge.""" + domain = _make_domain() + other: dict[str, t.Any] = { + TRANSFORM: {"pkg.Sibling": ("pageC", "anchor-sibling")}, + } + domain.merge_domaindata({"pageB"}, other) + assert domain.components(TRANSFORM) == {} + + +def test_get_objects_yields_every_registered_item() -> None: + """get_objects iterates all six component tables.""" + domain = _make_domain() + for objtype in OBJECT_TYPES: + domain.note_component( + objtype, + f"pkg.{objtype.title()}", + "api", + f"docutils-{objtype}-anchor", + ) + + rows = list(domain.get_objects()) + assert {row[2] for row in rows} == set(OBJECT_TYPES) + assert len(rows) == len(OBJECT_TYPES) + + +def test_lookup_exact_qualified_name() -> None: + """_lookup resolves a fully-qualified component path.""" + domain = _make_domain() + domain.note_component(TRANSFORM, "pkg.transforms.Sanitize", "api", "anchor") + assert domain._lookup(TRANSFORM, "pkg.transforms.Sanitize") == ("api", "anchor") + + +def test_lookup_bare_class_name_when_unambiguous() -> None: + """_lookup falls back to a unique bare class-name suffix match.""" + domain = _make_domain() + domain.note_component(TRANSFORM, "pkg.transforms.Sanitize", "api", "anchor") + assert domain._lookup(TRANSFORM, "Sanitize") == ("api", "anchor") + + +def test_lookup_bare_class_name_ambiguous_returns_none() -> None: + """_lookup refuses an ambiguous bare class-name match.""" + domain = _make_domain() + domain.note_component(TRANSFORM, "pkg_a.Sanitize", "api", "anchor-a") + domain.note_component(TRANSFORM, "pkg_b.Sanitize", "api", "anchor-b") + assert domain._lookup(TRANSFORM, "Sanitize") is None + + +def test_lookup_miss_returns_none() -> None: + """_lookup returns None for unknown targets and unknown objtypes.""" + domain = _make_domain() + assert domain._lookup(TRANSFORM, "pkg.Missing") is None + assert domain._lookup("not-an-objtype", "pkg.Missing") is None + + +def test_component_index_groups_by_objtype() -> None: + """The component index groups entries under per-objtype headings.""" + domain = _make_domain() + domain.note_component(TRANSFORM, "pkg.B", "api", "anchor-b") + domain.note_component(TRANSFORM, "pkg.A", "api", "anchor-a") + domain.note_component(WRITER, "pkg.W", "api", "anchor-w") + + index = DocutilsComponentIndex(domain) + content, collapse = index.generate() + + headings = [heading for heading, _entries in content] + assert headings == ["Transforms", "Writers"] + transform_entries = dict(content)["Transforms"] + assert [entry.name for entry in transform_entries] == ["pkg.A", "pkg.B"] + assert collapse is True + + +def test_component_index_filters_by_docnames() -> None: + """The component index honours the *docnames* filter.""" + domain = _make_domain() + domain.note_component(TRANSFORM, "pkg.A", "page-a", "anchor-a") + domain.note_component(TRANSFORM, "pkg.B", "page-b", "anchor-b") + + index = DocutilsComponentIndex(domain) + content, _collapse = index.generate(docnames=["page-b"]) + + assert dict(content)["Transforms"][0].name == "pkg.B" + assert len(dict(content)["Transforms"]) == 1 diff --git a/tests/ext/test_shared_stack_setup.py b/tests/ext/test_shared_stack_setup.py index 1ea2d694..28b15a1f 100644 --- a/tests/ext/test_shared_stack_setup.py +++ b/tests/ext/test_shared_stack_setup.py @@ -96,6 +96,7 @@ def test_shared_stack_setup_autoloads_expected_extensions(case: _SetupCase) -> N add_autodocumenter=lambda *args, **kwargs: None, add_crossref_type=lambda *args, **kwargs: None, add_node=lambda *args, **kwargs: None, + add_domain=lambda *args, **kwargs: None, ) metadata = case.setup(t.cast(Sphinx, app)) From 146ceedea9b963e0f14dd3926bd4b29ce29c6494 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 09:46:34 -0500 Subject: [PATCH 02/33] gp-sphinx(autodoc-sphinx[domain]): Add sphinxext cross-reference domain why: Issue #52 adds builder and domain autodoc to this package. Like the docutils component types, Builder and Domain subclasses have no existing Sphinx objtype, so cross-references and anchors need a home before autobuilder/autodomain can land. what: - Add SphinxExtDomain (name="sphinxext") with builder/domain object types, an XRefRole per objtype, and a grouped-by-objtype component index, mirroring the DocutilsDomain lifecycle shape - Add SphinxExtComponentDescription rendering dotted Python paths as module desc_addname + class desc_name, owning anchors and domain notes - Register the domain in setup(); add tests/ext/autodoc_sphinx package __init__ for parity with sibling test packages --- .../src/sphinx_autodoc_sphinx/__init__.py | 17 + .../src/sphinx_autodoc_sphinx/domain.py | 361 ++++++++++++++++++ tests/ext/autodoc_sphinx/__init__.py | 1 + tests/ext/autodoc_sphinx/test_domain.py | 248 ++++++++++++ 4 files changed, 627 insertions(+) create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/domain.py create mode 100644 tests/ext/autodoc_sphinx/__init__.py create mode 100644 tests/ext/autodoc_sphinx/test_domain.py diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index 1dd5cc45..5454c648 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -10,6 +10,18 @@ AutoconfigvalueDirective, AutoconfigvaluesDirective, ) +from sphinx_autodoc_sphinx.domain import ( + SphinxExtComponentIndex, + SphinxExtDomain, +) + +__all__ = [ + "AutoconfigvalueDirective", + "AutoconfigvaluesDirective", + "SphinxExtComponentIndex", + "SphinxExtDomain", + "setup", +] if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -30,6 +42,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: ... self.calls.append(("setup_extension", name)) ... def add_directive(self, name: str, directive: object) -> None: ... self.calls.append(("add_directive", name)) + ... def add_domain(self, domain: object) -> None: + ... self.calls.append(("add_domain", domain)) ... def connect(self, event: str, handler: object) -> None: ... self.calls.append(("connect", event)) ... def add_css_file(self, filename: str) -> None: @@ -38,6 +52,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_directive", "autoconfigvalue") in fake.calls True + >>> ("add_domain", SphinxExtDomain) in fake.calls + True >>> ("setup_extension", "sphinx_ux_autodoc_layout") in fake.calls True >>> ("add_css_file", "css/sphinx_autodoc_sphinx.css") in fake.calls @@ -48,6 +64,7 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.setup_extension("sphinx_ux_badges") app.setup_extension("sphinx_ux_autodoc_layout") app.setup_extension("sphinx_autodoc_typehints_gp") + app.add_domain(SphinxExtDomain) app.add_directive("autoconfigvalue", AutoconfigvalueDirective) app.add_directive("autoconfigvalues", AutoconfigvaluesDirective) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/domain.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/domain.py new file mode 100644 index 00000000..3a0888c7 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/domain.py @@ -0,0 +1,361 @@ +"""The sphinxext components Sphinx domain. + +Registers two object types (``builder``, ``domain``) with matching +cross-reference roles, one grouped-by-objtype index, and the standard +lifecycle hooks Sphinx expects from a parallel-safe domain. + +The component autodoc directives wire into this domain by generating +``.. sphinxext::: dotted.path.ClassName`` markup, so the +parsed ``desc`` nodes natively carry ``domain="sphinxext"`` and a +per-type ``objtype`` — the shared layout and badge pipelines key off +both. + +Examples +-------- +>>> from sphinx_autodoc_sphinx.domain import SphinxExtDomain +>>> SphinxExtDomain.name +'sphinxext' +>>> sorted(SphinxExtDomain.object_types) +['builder', 'domain'] +>>> sorted(SphinxExtDomain.roles) == sorted(SphinxExtDomain.object_types) +True +>>> [cls.name for cls in SphinxExtDomain.indices] +['componentindex'] +""" + +from __future__ import annotations + +import typing as t + +from docutils.parsers.rst import directives +from sphinx import addnodes +from sphinx.directives import ObjectDescription +from sphinx.domains import Domain, Index, IndexEntry, ObjType +from sphinx.locale import _ +from sphinx.roles import XRefRole +from sphinx.util.nodes import make_id, make_refnode + +if t.TYPE_CHECKING: + from collections.abc import Iterable, Iterator, Set + + from docutils import nodes + from docutils.nodes import Element + from sphinx.addnodes import pending_xref + from sphinx.builders import Builder + from sphinx.environment import BuildEnvironment + from sphinx.util.typing import OptionSpec + + +#: Object type name used for Sphinx builders. +BUILDER = "builder" +#: Object type name used for Sphinx domains. +DOMAIN = "domain" + +#: All object type names in a single tuple for iteration. +OBJECT_TYPES: tuple[str, ...] = (BUILDER, DOMAIN) + +#: Index group headings keyed by object type. +_INDEX_HEADINGS: dict[str, str] = { + BUILDER: "Builders", + DOMAIN: "Domains", +} + + +def split_component_path(path: str) -> tuple[str, str]: + """Split a dotted component path into ``(module, class_name)``. + + Examples + -------- + >>> split_component_path("sphinx.builders.dummy.DummyBuilder") + ('sphinx.builders.dummy', 'DummyBuilder') + + >>> split_component_path("DummyBuilder") + ('', 'DummyBuilder') + """ + module_name, _sep, class_name = path.rpartition(".") + return module_name, class_name + + +class SphinxExtComponentDescription(ObjectDescription[str]): + """Object description for one Sphinx extension component class. + + The signature argument is a dotted Python path + (``pkg.module.ClassName``); the module prefix renders as + ``desc_addname`` and the class name as ``desc_name``, matching the + ``py:class`` visual structure. The anchor and cross-reference + target are owned by :class:`SphinxExtDomain`. + """ + + option_spec: t.ClassVar[OptionSpec] = { + "no-index": directives.flag, + } + + def handle_signature( + self, + sig: str, + sig_node: addnodes.desc_signature, + ) -> str: + """Render *sig* (a dotted path) into the signature node.""" + path = sig.strip() + module_name, class_name = split_component_path(path) + if module_name: + sig_node += addnodes.desc_addname( + f"{module_name}.", + f"{module_name}.", + ) + sig_node += addnodes.desc_name(class_name, class_name) + sig_node["fullname"] = path + return path + + def _object_hierarchy_parts( + self, + sig_node: addnodes.desc_signature, + ) -> tuple[str, ...]: + """Return the TOC hierarchy parts for *sig_node*.""" + return (str(sig_node["fullname"]),) + + def _toc_entry_name(self, sig_node: addnodes.desc_signature) -> str: + """Return the local-TOC entry text (the bare class name).""" + if not sig_node.get("_toc_parts"): + return "" + (name,) = sig_node["_toc_parts"] + return split_component_path(str(name))[1] + + def add_target_and_index( + self, + name: str, + sig: str, + signode: addnodes.desc_signature, + ) -> None: + """Create the anchor and note the component in the domain.""" + node_id = make_id( + self.env, + self.state.document, + f"sphinxext-{self.objtype}", + name, + ) + signode["ids"].append(node_id) + self.state.document.note_explicit_target(signode) + domain = t.cast("SphinxExtDomain", self.env.domains[SphinxExtDomain.name]) + domain.note_component(self.objtype, name, self.env.docname, node_id) + + +class SphinxExtComponentIndex(Index): + """Grouped-by-objtype index of every registered extension component. + + The generated page lives at ``sphinxext-componentindex.html`` and + can be linked via ``:ref:`sphinxext-componentindex```. + + Examples + -------- + >>> SphinxExtComponentIndex.name + 'componentindex' + >>> str(SphinxExtComponentIndex.localname) + 'Sphinx extension components index' + """ + + name = "componentindex" + localname = _("Sphinx extension components index") + shortname = _("components") + + def generate( + self, + docnames: Iterable[str] | None = None, + ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: + """Build the component index entries grouped by object type.""" + content: dict[str, list[IndexEntry]] = {} + allowed = set(docnames) if docnames is not None else None + + for objtype in OBJECT_TYPES: + table: dict[str, tuple[str, str]] = self.domain.data.get(objtype, {}) + for name in sorted(table): + docname, anchor = table[name] + if allowed is not None and docname not in allowed: + continue + heading = _INDEX_HEADINGS[objtype] + content.setdefault(heading, []).append( + IndexEntry( + name=name, + subtype=0, + docname=docname, + anchor=anchor, + extra="", + qualifier="", + descr=_(objtype), + ), + ) + + return ( + sorted(content.items()), + True, + ) + + +class SphinxExtDomain(Domain): + """Sphinx domain for extension component documentation. + + Stores one dictionary per object type under + ``env.domaindata["sphinxext"]``:: + + data[objtype][qualified_name] = (docname, anchor) + + Components are keyed by their fully-qualified dotted Python path + (``"pkg.module.ClassName"``), which is unique per class. Lookup + additionally accepts the bare class name when it matches exactly + one registered component. + + Examples + -------- + >>> SphinxExtDomain.name + 'sphinxext' + >>> SphinxExtDomain.data_version + 0 + """ + + name = "sphinxext" + label = "Sphinx extensions" + + object_types = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + BUILDER: ObjType(_("builder"), BUILDER), + DOMAIN: ObjType(_("domain"), DOMAIN), + } + + directives = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + BUILDER: SphinxExtComponentDescription, + DOMAIN: SphinxExtComponentDescription, + } + + roles = { # noqa: RUF012 — XRefRole instances are safe to share across domains + BUILDER: XRefRole(), + DOMAIN: XRefRole(), + } + + indices = [ # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + SphinxExtComponentIndex, + ] + + initial_data = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + BUILDER: {}, + DOMAIN: {}, + } + + data_version = 0 + + def components(self, objtype: str) -> dict[str, tuple[str, str]]: + """Return *objtype*'s table: ``qualified_name -> (docname, anchor)``.""" + return t.cast( + "dict[str, tuple[str, str]]", + self.data.setdefault(objtype, {}), + ) + + def note_component( + self, + objtype: str, + name: str, + docname: str, + anchor: str, + ) -> None: + """Record a component target in the domain data.""" + self.components(objtype)[name] = (docname, anchor) + + def clear_doc(self, docname: str) -> None: + """Drop every entry that came from *docname* so it can be re-built.""" + for objtype in OBJECT_TYPES: + table = self.components(objtype) + for name, (existing, _anchor) in list(table.items()): + if existing == docname: + del table[name] + + def merge_domaindata( + self, + docnames: Set[str], + otherdata: dict[str, t.Any], + ) -> None: + """Merge sibling worker's ``domaindata`` under parallel builds.""" + for objtype in OBJECT_TYPES: + for name, (docname, anchor) in otherdata.get(objtype, {}).items(): + if docname in docnames: + self.components(objtype)[name] = (docname, anchor) + + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + typ: str, + target: str, + node: pending_xref, + contnode: Element, + ) -> nodes.reference | None: + """Resolve a single typed cross-reference to a docutils reference.""" + match = self._lookup(typ, target) + if match is None: + return None + todocname, anchor = match + return make_refnode( + builder, + fromdocname, + todocname, + anchor, + contnode, + target, + ) + + def resolve_any_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + target: str, + node: pending_xref, + contnode: Element, + ) -> list[tuple[str, nodes.reference]]: + """Resolve an untyped ``:any:`` cross-reference across object types.""" + results: list[tuple[str, nodes.reference]] = [] + for objtype in OBJECT_TYPES: + match = self._lookup(objtype, target) + if match is None: + continue + todocname, anchor = match + results.append( + ( + f"sphinxext:{objtype}", + make_refnode( + builder, + fromdocname, + todocname, + anchor, + contnode, + target, + ), + ), + ) + return results + + def get_objects(self) -> Iterator[tuple[str, str, str, str, str, int]]: + """Yield ``(name, dispname, type, docname, anchor, priority)`` rows.""" + for objtype in OBJECT_TYPES: + for name, (docname, anchor) in self.components(objtype).items(): + yield name, name, objtype, docname, anchor, 1 + + def _lookup(self, typ: str, target: str) -> tuple[str, str] | None: + """Look up *target* in *typ*'s table, accepting bare class names. + + Components are stored under fully-qualified dotted paths. + Authors commonly write the bare class name (``DummyBuilder``); + fall back to a suffix match when it identifies exactly one + component. + """ + if typ not in OBJECT_TYPES: + return None + table = self.components(typ) + if target in table: + return table[target] + candidates = [ + value + for name, value in table.items() + if split_component_path(name)[1] == target + ] + if len(candidates) == 1: + return candidates[0] + return None diff --git a/tests/ext/autodoc_sphinx/__init__.py b/tests/ext/autodoc_sphinx/__init__.py new file mode 100644 index 00000000..6384f504 --- /dev/null +++ b/tests/ext/autodoc_sphinx/__init__.py @@ -0,0 +1 @@ +"""Tests for sphinx_autodoc_sphinx.""" diff --git a/tests/ext/autodoc_sphinx/test_domain.py b/tests/ext/autodoc_sphinx/test_domain.py new file mode 100644 index 00000000..360934c1 --- /dev/null +++ b/tests/ext/autodoc_sphinx/test_domain.py @@ -0,0 +1,248 @@ +"""Unit tests for SphinxExtDomain. + +Constructs SphinxExtDomain against a lightweight stub env and exercises +the note / clear / merge / resolve lifecycle plus the grouped component +index. Cross-reference resolution against a real Sphinx build lives in +``test_domain_xref_integration.py``. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from sphinx_autodoc_sphinx.domain import ( + BUILDER, + DOMAIN, + OBJECT_TYPES, + SphinxExtComponentIndex, + SphinxExtDomain, + split_component_path, +) + + +class _StubEnv: + """Minimal stand-in for ``BuildEnvironment`` — just ``domaindata``.""" + + def __init__(self) -> None: + self.domaindata: dict[str, dict[str, t.Any]] = {} + + +def _make_domain() -> SphinxExtDomain: + """Build a SphinxExtDomain bound to a fresh stub environment.""" + return SphinxExtDomain(t.cast("t.Any", _StubEnv())) + + +def test_object_types_constants_match_domain() -> None: + """Module-level objtype names match the domain's registered keys.""" + assert set(OBJECT_TYPES) == {BUILDER, DOMAIN} + assert set(SphinxExtDomain.object_types) == set(OBJECT_TYPES) + assert set(SphinxExtDomain.roles) == set(OBJECT_TYPES) + assert set(SphinxExtDomain.directives) == set(OBJECT_TYPES) + + +def test_initial_data_contains_empty_tables() -> None: + """Fresh domain starts with one empty table per object type.""" + domain = _make_domain() + for objtype in OBJECT_TYPES: + assert domain.components(objtype) == {} + + +class SplitPathCase(t.NamedTuple): + """Test case for split_component_path().""" + + test_id: str + path: str + expected_module: str + expected_class: str + + +_SPLIT_PATH_CASES: list[SplitPathCase] = [ + SplitPathCase( + test_id="dotted_path", + path="sphinx.builders.dummy.DummyBuilder", + expected_module="sphinx.builders.dummy", + expected_class="DummyBuilder", + ), + SplitPathCase( + test_id="single_segment", + path="DummyBuilder", + expected_module="", + expected_class="DummyBuilder", + ), + SplitPathCase( + test_id="deeply_nested", + path="a.b.c.d.MyDomain", + expected_module="a.b.c.d", + expected_class="MyDomain", + ), +] + + +@pytest.mark.parametrize( + "case", + _SPLIT_PATH_CASES, + ids=lambda c: c.test_id, +) +def test_split_component_path(case: SplitPathCase) -> None: + """split_component_path divides on the final dot.""" + assert split_component_path(case.path) == ( + case.expected_module, + case.expected_class, + ) + + +class NoteComponentCase(t.NamedTuple): + """Test case for SphinxExtDomain.note_component() per objtype.""" + + test_id: str + objtype: str + name: str + docname: str + anchor: str + + +_NOTE_COMPONENT_CASES: list[NoteComponentCase] = [ + NoteComponentCase( + test_id="builder", + objtype=BUILDER, + name="pkg.builders.DemoBuilder", + docname="api", + anchor="sphinxext-builder-pkg-builders-demobuilder", + ), + NoteComponentCase( + test_id="domain", + objtype=DOMAIN, + name="pkg.domain.DemoDomain", + docname="api", + anchor="sphinxext-domain-pkg-domain-demodomain", + ), +] + + +@pytest.mark.parametrize( + "case", + _NOTE_COMPONENT_CASES, + ids=lambda c: c.test_id, +) +def test_note_component_records_docname_and_anchor(case: NoteComponentCase) -> None: + """note_component stores (docname, anchor) under the qualified name.""" + domain = _make_domain() + domain.note_component(case.objtype, case.name, case.docname, case.anchor) + assert domain.components(case.objtype) == { + case.name: (case.docname, case.anchor), + } + for other in OBJECT_TYPES: + if other != case.objtype: + assert domain.components(other) == {} + + +def test_clear_doc_removes_only_matching_docname() -> None: + """clear_doc drops entries from *docname* and keeps the rest.""" + domain = _make_domain() + domain.note_component(BUILDER, "pkg.A", "page-a", "anchor-a") + domain.note_component(BUILDER, "pkg.B", "page-b", "anchor-b") + domain.note_component(DOMAIN, "pkg.D", "page-a", "anchor-d") + + domain.clear_doc("page-a") + + assert domain.components(BUILDER) == {"pkg.B": ("page-b", "anchor-b")} + assert domain.components(DOMAIN) == {} + + +def test_merge_domaindata_merges_entries_within_docnames() -> None: + """Parallel-worker merge retains entries for docnames in the active set.""" + domain = _make_domain() + other: dict[str, t.Any] = { + BUILDER: {"pkg.Sibling": ("pageB", "anchor-sibling")}, + DOMAIN: {"pkg.D": ("pageB", "anchor-d")}, + } + domain.merge_domaindata({"pageB"}, other) + assert domain.components(BUILDER) == {"pkg.Sibling": ("pageB", "anchor-sibling")} + assert domain.components(DOMAIN) == {"pkg.D": ("pageB", "anchor-d")} + + +def test_merge_domaindata_ignores_entries_outside_docnames() -> None: + """Entries whose docname is NOT in *docnames* are dropped on merge.""" + domain = _make_domain() + other: dict[str, t.Any] = { + BUILDER: {"pkg.Sibling": ("pageC", "anchor-sibling")}, + } + domain.merge_domaindata({"pageB"}, other) + assert domain.components(BUILDER) == {} + + +def test_get_objects_yields_every_registered_item() -> None: + """get_objects iterates both component tables.""" + domain = _make_domain() + for objtype in OBJECT_TYPES: + domain.note_component( + objtype, + f"pkg.{objtype.title()}", + "api", + f"sphinxext-{objtype}-anchor", + ) + + rows = list(domain.get_objects()) + assert {row[2] for row in rows} == set(OBJECT_TYPES) + assert len(rows) == len(OBJECT_TYPES) + + +def test_lookup_exact_qualified_name() -> None: + """_lookup resolves a fully-qualified component path.""" + domain = _make_domain() + domain.note_component(BUILDER, "pkg.builders.Demo", "api", "anchor") + assert domain._lookup(BUILDER, "pkg.builders.Demo") == ("api", "anchor") + + +def test_lookup_bare_class_name_when_unambiguous() -> None: + """_lookup falls back to a unique bare class-name suffix match.""" + domain = _make_domain() + domain.note_component(BUILDER, "pkg.builders.Demo", "api", "anchor") + assert domain._lookup(BUILDER, "Demo") == ("api", "anchor") + + +def test_lookup_bare_class_name_ambiguous_returns_none() -> None: + """_lookup refuses an ambiguous bare class-name match.""" + domain = _make_domain() + domain.note_component(BUILDER, "pkg_a.Demo", "api", "anchor-a") + domain.note_component(BUILDER, "pkg_b.Demo", "api", "anchor-b") + assert domain._lookup(BUILDER, "Demo") is None + + +def test_lookup_miss_returns_none() -> None: + """_lookup returns None for unknown targets and unknown objtypes.""" + domain = _make_domain() + assert domain._lookup(BUILDER, "pkg.Missing") is None + assert domain._lookup("not-an-objtype", "pkg.Missing") is None + + +def test_component_index_groups_by_objtype() -> None: + """The component index groups entries under per-objtype headings.""" + domain = _make_domain() + domain.note_component(BUILDER, "pkg.B", "api", "anchor-b") + domain.note_component(BUILDER, "pkg.A", "api", "anchor-a") + domain.note_component(DOMAIN, "pkg.D", "api", "anchor-d") + + index = SphinxExtComponentIndex(domain) + content, collapse = index.generate() + + headings = [heading for heading, _entries in content] + assert headings == ["Builders", "Domains"] + builder_entries = dict(content)["Builders"] + assert [entry.name for entry in builder_entries] == ["pkg.A", "pkg.B"] + assert collapse is True + + +def test_component_index_filters_by_docnames() -> None: + """The component index honours the *docnames* filter.""" + domain = _make_domain() + domain.note_component(BUILDER, "pkg.A", "page-a", "anchor-a") + domain.note_component(BUILDER, "pkg.B", "page-b", "anchor-b") + + index = SphinxExtComponentIndex(domain) + content, _collapse = index.generate(docnames=["page-b"]) + + assert dict(content)["Builders"][0].name == "pkg.B" + assert len(dict(content)["Builders"]) == 1 From 72a9da66f41e119c54e39ccf27c6ce2ab93ce987 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 09:57:28 -0500 Subject: [PATCH 03/33] gp-sphinx(autodoc-docutils[autotransform]): Document docutils transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Issue #52 — transforms are the first of six docutils component types gaining registry-aware autodoc. django-docutils ships three Transform subclasses documented today with plain automodule; this gives them option-table-grade output with priority and phase facts. what: - Add autotransform/autotransforms directives discovering transforms from recorded app.add_transform()/app.add_post_transform() calls, with a module subclass-scan fallback for unregistered transforms - Add _components.py: the shared markup -> parse -> badges -> facts pipeline all six component types render through, emitting docutils-domain object descriptions - Badge group: filled "transform" kind badge plus outlined "priority N" badge from default_priority (SAB.TYPE_TRANSFORM, SAB.MOD_PRIORITY with light/dark palette tokens) - Register the ("docutils", "transform") layout profile so entries get the shared card treatment - Domains now construct XRefRole(warn_dangling=True) so dangling component refs warn like std:confval refs do - Demo transform + examples page sections (single, bulk, cross-reference) and a two-page xref-resolution integration scenario asserting resolve-clean, dangling-warns, and href output --- docs/_ext/docutils_demo_components.py | 65 ++++ .../sphinx-autodoc-docutils/examples.md | 26 ++ .../src/sphinx_autodoc_docutils/__init__.py | 16 + .../src/sphinx_autodoc_docutils/_badges.py | 44 +++ .../sphinx_autodoc_docutils/_components.py | 231 ++++++++++++++ .../_transforms_doc.py | 235 ++++++++++++++ .../src/sphinx_autodoc_docutils/domain.py | 12 +- .../src/sphinx_autodoc_sphinx/domain.py | 4 +- .../sphinx_ux_autodoc_layout/_transforms.py | 10 + .../src/sphinx_ux_badges/_css.py | 6 + .../_static/css/sab_palettes.css | 33 ++ .../test_autodoc_docutils_integration.py | 34 ++ tests/ext/autodoc_docutils/test_components.py | 298 ++++++++++++++++++ .../test_domain_xref_integration.py | 164 ++++++++++ 14 files changed, 1170 insertions(+), 8 deletions(-) create mode 100644 docs/_ext/docutils_demo_components.py create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py create mode 100644 tests/ext/autodoc_docutils/test_components.py create mode 100644 tests/ext/autodoc_docutils/test_domain_xref_integration.py diff --git a/docs/_ext/docutils_demo_components.py b/docs/_ext/docutils_demo_components.py new file mode 100644 index 00000000..b56d4ddb --- /dev/null +++ b/docs/_ext/docutils_demo_components.py @@ -0,0 +1,65 @@ +"""Synthetic docutils components for live component-autodoc demos. + +Grows one demo class per component type so the +``docs/packages/sphinx-autodoc-docutils`` examples page can exercise +every ``auto*`` directive against realistic metadata. + +Examples +-------- +>>> DemoReorderTransform.default_priority +760 +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.transforms import Transform + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.util.typing import ExtensionMetadata + + +class DemoReorderTransform(Transform): + """Move demo-badge paragraphs ahead of their sibling paragraphs. + + Runs late in the read phase (priority 760) so it sees the fully + parsed document but still precedes reference resolution. + """ + + default_priority = 760 + + def apply(self) -> None: + """Hoist each ``demo-badge`` paragraph to the front of its parent.""" + for paragraph in tuple(self.document.findall(nodes.paragraph)): + if "demo-badge" in paragraph.get("classes", ()): + parent = paragraph.parent + parent.remove(paragraph) + parent.insert(0, paragraph) + + +def setup(app: Sphinx) -> ExtensionMetadata: + """Register the demo components with Sphinx. + + Examples + -------- + >>> class FakeApp: + ... def __init__(self) -> None: + ... self.calls: list[tuple[str, object]] = [] + ... def add_transform(self, cls: object) -> None: + ... self.calls.append(("add_transform", cls)) + >>> fake = FakeApp() + >>> metadata = setup(fake) # type: ignore[arg-type] + >>> ("add_transform", DemoReorderTransform) in fake.calls + True + >>> metadata["parallel_read_safe"] + True + """ + app.add_transform(DemoReorderTransform) + return { + "version": "0.0.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/packages/sphinx-autodoc-docutils/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md index c2d9d015..ad175726 100644 --- a/docs/packages/sphinx-autodoc-docutils/examples.md +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -40,6 +40,32 @@ Renders all role callables in a module at once: :no-index: ``` +### Document one demo transform + +The single form imports the class directly and surfaces its +`default_priority` and registration phase: + +```{eval-rst} +.. autotransform:: docutils_demo_components.DemoReorderTransform +``` + +### Bulk transforms demo + +Renders every transform a module registers via `setup()` — here the +demo module's `app.add_transform()` call: + +```{eval-rst} +.. autotransforms:: docutils_demo_components + :no-index: +``` + +### Cross-referencing components + +Component entries register targets in the `docutils` domain, so prose +can link to them: {docutils:transform}`DemoReorderTransform` resolves +to the entry above, and {docutils:transform}`docutils_demo_components.DemoReorderTransform` +spells out the full path. + The extension itself registers directives, not docutils roles or Sphinx config values. The generated package reference below lists its registered surface from the live `setup()` calls. diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index c3c76211..670c8475 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -14,6 +14,13 @@ SetupRecorder, replay_setup, ) +from sphinx_autodoc_docutils._transforms_doc import ( + AutoTransform, + AutoTransforms, + TransformInfo, + discover_transform, + discover_transforms, +) from sphinx_autodoc_docutils.domain import ( DocutilsComponentIndex, DocutilsDomain, @@ -24,9 +31,14 @@ "AutoDirectives", "AutoRole", "AutoRoles", + "AutoTransform", + "AutoTransforms", "DocutilsComponentIndex", "DocutilsDomain", "SetupRecorder", + "TransformInfo", + "discover_transform", + "discover_transforms", "replay_setup", "setup", ] @@ -60,6 +72,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_directive", "autodirective") in fake.calls True + >>> ("add_directive", "autotransform") in fake.calls + True >>> ("add_domain", DocutilsDomain) in fake.calls True >>> ("setup_extension", "sphinx_ux_autodoc_layout") in fake.calls @@ -77,6 +91,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_directive("autodirectives", AutoDirectives) app.add_directive("autorole", AutoRole) app.add_directive("autoroles", AutoRoles) + app.add_directive("autotransform", AutoTransform) + app.add_directive("autotransforms", AutoTransforms) _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py index 5bc062de..3a6f50b7 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py @@ -12,6 +12,7 @@ "directive": SAB.TYPE_DIRECTIVE, "role": SAB.TYPE_ROLE, "option": SAB.TYPE_OPTION, + "transform": SAB.TYPE_TRANSFORM, } @@ -39,3 +40,46 @@ def build_kind_badge_group(kind: str) -> nodes.inline: ], classes=[_GROUP_CLASS], ) + + +def build_transform_badge_group(priority: int | None = None) -> nodes.inline: + """Return header badges for one documented docutils transform. + + Parameters + ---------- + priority : int | None + The transform's ``default_priority``; rendered as an outlined + secondary badge when set. + + Returns + ------- + nodes.inline + Badge group for the entry header. + + Examples + -------- + >>> group = build_transform_badge_group(830) + >>> "transform" in group.astext() + True + >>> "priority 830" in group.astext() + True + >>> "priority" in build_transform_badge_group(None).astext() + False + """ + specs = [ + BadgeSpec( + "transform", + tooltip="Docutils transform", + classes=(SAB.TYPE_TRANSFORM,), + ), + ] + if priority is not None: + specs.append( + BadgeSpec( + f"priority {priority}", + tooltip="Transform default_priority", + classes=(SAB.MOD_PRIORITY,), + fill="outline", + ), + ) + return build_badge_group_from_specs(specs, classes=[_GROUP_CLASS]) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py new file mode 100644 index 00000000..e069fadb --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py @@ -0,0 +1,231 @@ +"""Shared rendering pipeline for docutils component autodoc entries. + +Every component type (transform, reader, parser, writer, node, +translator) renders through the same three steps: + +1. :func:`component_markup` builds ``.. docutils:::`` markup + so parsed ``desc`` nodes natively carry ``domain="docutils"``. +2. :func:`inject_component_badges` attaches the kind badge group to + each signature. +3. :func:`normalize_component_nodes` inserts the shared fact rows after + the summary paragraphs. + +:func:`render_component_nodes` chains all three for the ``Auto*`` +directives. +""" + +from __future__ import annotations + +import importlib +import inspect +import typing as t + +from sphinx import addnodes + +from sphinx_autodoc_docutils._directives import ( + _content_node, + _insert_after_summary, + _module_members, +) +from sphinx_ux_autodoc_layout import ( + build_api_facts_section, + inject_signature_slots, + iter_desc_nodes, + parse_generated_markup, +) + +if t.TYPE_CHECKING: + from docutils import nodes + from sphinx.util.docutils import SphinxDirective + + from sphinx_ux_autodoc_layout import ApiFactRow + +_T = t.TypeVar("_T") + + +def component_markup( + objtype: str, + path: str, + summary: str, + *, + no_index: bool = False, +) -> str: + """Return reStructuredText markup documenting one component class. + + Examples + -------- + >>> markup = component_markup( + ... "transform", + ... "pkg.transforms.Sanitize", + ... "Strip unsafe nodes.", + ... ) + >>> ".. docutils:transform:: pkg.transforms.Sanitize" in markup + True + >>> "Strip unsafe nodes." in markup + True + >>> ":no-index:" in component_markup("node", "pkg.icon", "", no_index=True) + True + """ + return "\n".join( + [ + f".. docutils:{objtype}:: {path}", + " :no-index:" if no_index else "", + "", + f" {summary or f'Autodocumented docutils {objtype}.'}", + ], + ) + + +def component_classes( + module_name: str, + base: type[_T], +) -> list[type[_T]]: + """Return public subclasses of *base* defined directly in a module. + + The base class itself is excluded even when re-exported, so passing + ``docutils.transforms`` never surfaces ``Transform`` as a documented + component. + + Examples + -------- + >>> from docutils.transforms import Transform + >>> classes = component_classes("docutils.transforms.misc", Transform) + >>> sorted(cls.__name__ for cls in classes) + ['CallBack', 'ClassAttribute', 'Transitions'] + + >>> component_classes("sphinx_fonts", Transform) + [] + """ + importlib.import_module(module_name) + results: list[type[_T]] = [] + for _name, value in _module_members(module_name): + if inspect.isclass(value) and issubclass(value, base) and value is not base: + results.append(value) + return results + + +def inject_component_badges( + node_list: list[nodes.Node], + *, + objtype: str, + badge_group: nodes.inline, +) -> None: + """Attach shared badge-slot metadata to parsed ``docutils:*`` entries. + + Examples + -------- + >>> from sphinx import addnodes + >>> from sphinx_autodoc_docutils._badges import build_kind_badge_group + >>> desc = addnodes.desc(domain="docutils", objtype="transform") + >>> sig = addnodes.desc_signature() + >>> desc += sig + >>> inject_component_badges( + ... [desc], + ... objtype="transform", + ... badge_group=build_kind_badge_group("transform"), + ... ) + >>> sig["sadoc_badges_injected"] + True + + Entries of another objtype are left untouched: + + >>> other = addnodes.desc(domain="docutils", objtype="writer") + >>> other_sig = addnodes.desc_signature() + >>> other += other_sig + >>> inject_component_badges( + ... [other], + ... objtype="transform", + ... badge_group=build_kind_badge_group("transform"), + ... ) + >>> other_sig.get("sadoc_badges_injected") is None + True + """ + for desc_node in iter_desc_nodes(node_list): + if desc_node.get("domain") != "docutils" or desc_node.get("objtype") != objtype: + continue + for sig_node in desc_node.children: + if not isinstance(sig_node, addnodes.desc_signature): + continue + inject_signature_slots( + sig_node, + marker_attr="sadoc_badges_injected", + badge_node=badge_group.deepcopy(), + extract_source_link=False, + ) + + +def normalize_component_nodes( + node_list: list[nodes.Node], + *, + objtype: str, + fact_rows: list[ApiFactRow], +) -> None: + """Attach the shared facts section to parsed component entries. + + The facts section lands directly after the leading summary + paragraphs inside ``desc_content``. + + Examples + -------- + >>> from docutils import nodes as docutils_nodes + >>> from sphinx import addnodes + >>> from sphinx_ux_autodoc_layout import ApiFactRow + >>> desc = addnodes.desc(domain="docutils", objtype="transform") + >>> desc += addnodes.desc_signature() + >>> content = addnodes.desc_content() + >>> content += docutils_nodes.paragraph("", "Summary.") + >>> desc += content + >>> body = docutils_nodes.paragraph() + >>> body += docutils_nodes.literal("demo", "demo") + >>> normalize_component_nodes( + ... [desc], + ... objtype="transform", + ... fact_rows=[ApiFactRow("Python path", body)], + ... ) + >>> content.children[1].get("name") + 'gp-sphinx-api-facts' + """ + for desc_node in iter_desc_nodes(node_list): + if desc_node.get("domain") != "docutils" or desc_node.get("objtype") != objtype: + continue + content = _content_node(desc_node) + if content is None: + continue + _insert_after_summary(content, build_api_facts_section(fact_rows)) + + +def render_component_nodes( + directive: SphinxDirective, + *, + objtype: str, + path: str, + summary: str, + fact_rows: list[ApiFactRow], + badge_group: nodes.inline, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one component entry with badges and facts attached.""" + node_list = parse_generated_markup( + directive, + component_markup(objtype, path, summary, no_index=no_index), + ) + inject_component_badges(node_list, objtype=objtype, badge_group=badge_group) + normalize_component_nodes(node_list, objtype=objtype, fact_rows=fact_rows) + return node_list + + +def import_component(path: str) -> type: + """Import one component class from a dotted ``module.ClassName`` path. + + Examples + -------- + >>> cls = import_component("docutils.transforms.misc.Transitions") + >>> cls.__name__ + 'Transitions' + """ + module_name, _, attr_name = path.rpartition(".") + value = getattr(importlib.import_module(module_name), attr_name) + if not inspect.isclass(value): + msg = f"Expected a class at {path!r}, got {type(value).__name__}" + raise TypeError(msg) + return t.cast("type", value) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py new file mode 100644 index 00000000..91b21933 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py @@ -0,0 +1,235 @@ +"""Rendering directives for docutils transform documentation.""" + +from __future__ import annotations + +import inspect +import typing as t +from dataclasses import dataclass + +from docutils.parsers.rst import directives +from docutils.transforms import Transform +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_docutils._badges import build_transform_badge_group +from sphinx_autodoc_docutils._components import ( + component_classes, + import_component, + render_component_nodes, +) +from sphinx_autodoc_docutils._directives import ( + _literal_paragraph, + _summary, + replay_setup, +) +from sphinx_autodoc_docutils.domain import TRANSFORM +from sphinx_ux_autodoc_layout import ApiFactRow + +if t.TYPE_CHECKING: + from docutils import nodes + from sphinx.util.typing import OptionSpec + +#: Recorder call names that register a transform with Sphinx. +_TRANSFORM_CALLS: tuple[str, ...] = ("add_transform", "add_post_transform") + + +@dataclass(frozen=True) +class TransformInfo: + """Recorded metadata for one documented transform class. + + Examples + -------- + >>> from docutils.transforms.misc import Transitions + >>> info = TransformInfo(cls=Transitions, registered_via="add_transform") + >>> info.qualified_name + 'docutils.transforms.misc.Transitions' + >>> info.priority + 830 + """ + + cls: type[Transform] + registered_via: str = "" + + @property + def qualified_name(self) -> str: + """Return the fully-qualified dotted path for the class. + + Examples + -------- + >>> from docutils.transforms.misc import CallBack + >>> TransformInfo(cls=CallBack).qualified_name + 'docutils.transforms.misc.CallBack' + """ + return f"{self.cls.__module__}.{self.cls.__name__}" + + @property + def priority(self) -> int | None: + """Return the transform's ``default_priority`` (None on bases). + + Examples + -------- + >>> from docutils.transforms.misc import CallBack + >>> TransformInfo(cls=CallBack).priority + 990 + """ + return self.cls.default_priority + + +def _transforms_from_calls( + calls: list[tuple[str, tuple[object, ...], dict[str, object]]], +) -> list[TransformInfo]: + """Extract transform metadata from recorded ``setup()`` calls. + + Examples + -------- + >>> from docutils.transforms.misc import CallBack, Transitions + >>> infos = _transforms_from_calls( + ... [ + ... ("add_transform", (Transitions,), {}), + ... ("add_post_transform", (CallBack,), {}), + ... ("add_directive", ("noise", object), {}), + ... ], + ... ) + >>> [(info.cls.__name__, info.registered_via) for info in infos] + [('Transitions', 'add_transform'), ('CallBack', 'add_post_transform')] + """ + infos: list[TransformInfo] = [] + seen: set[tuple[type[Transform], str]] = set() + for call_name, args, _kwargs in calls: + if call_name not in _TRANSFORM_CALLS or len(args) < 1: + continue + cls = args[0] + if not (inspect.isclass(cls) and issubclass(cls, Transform)): + continue + key = (cls, call_name) + if key in seen: + continue + seen.add(key) + infos.append(TransformInfo(cls=cls, registered_via=call_name)) + return infos + + +def discover_transforms(module_name: str) -> list[TransformInfo]: + """Return transforms a module registers, or defines as a fallback. + + Replays the module's ``setup()`` against a recorder so transforms + surface with their real registration phase (``add_transform`` vs + ``add_post_transform``). Falls back to scanning the module for + public :class:`~docutils.transforms.Transform` subclasses when no + ``setup()`` registers any. + + Examples + -------- + >>> infos = discover_transforms("docutils.transforms.misc") + >>> sorted(info.cls.__name__ for info in infos) + ['CallBack', 'ClassAttribute', 'Transitions'] + >>> {info.registered_via for info in infos} + {''} + + >>> discover_transforms("sphinx_fonts") + [] + """ + recorder = replay_setup(module_name) + if recorder is not None: + infos = _transforms_from_calls(recorder.calls) + if infos: + return infos + return [TransformInfo(cls=cls) for cls in component_classes(module_name, Transform)] + + +def discover_transform(path: str) -> TransformInfo: + """Return one transform from a fully-qualified dotted path. + + Examples + -------- + >>> info = discover_transform("docutils.transforms.misc.Transitions") + >>> info.cls.__name__ + 'Transitions' + """ + cls = t.cast("type[Transform]", import_component(path)) + for info in discover_transforms(cls.__module__): + if info.cls is cls: + return info + return TransformInfo(cls=cls) + + +def _transform_fact_rows(info: TransformInfo) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented transform. + + Examples + -------- + >>> from docutils.transforms.misc import Transitions + >>> rows = _transform_fact_rows( + ... TransformInfo(cls=Transitions, registered_via="add_transform"), + ... ) + >>> [row.label for row in rows] + ['Python path', 'Default priority', 'Registered via'] + """ + rows = [ + ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow( + "Default priority", + _literal_paragraph( + str(info.priority) if info.priority is not None else "—", + ), + ), + ] + if info.registered_via: + rows.append( + ApiFactRow( + "Registered via", + _literal_paragraph(f"app.{info.registered_via}()"), + ), + ) + return rows + + +def _render_transform( + directive: SphinxDirective, + info: TransformInfo, + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one transform entry through the shared component pipeline.""" + return render_component_nodes( + directive, + objtype=TRANSFORM, + path=info.qualified_name, + summary=_summary(info.cls), + fact_rows=_transform_fact_rows(info), + badge_group=build_transform_badge_group(info.priority), + no_index=no_index, + ) + + +class AutoTransform(SphinxDirective): + """Render documentation for a single transform class.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + info = discover_transform(self.arguments[0]) + return _render_transform(self, info, no_index="no-index" in self.options) + + +class AutoTransforms(SphinxDirective): + """Render documentation for every transform a package registers. + + Accepts either an extension package (whose ``setup()`` runs against + a recorder so each ``app.add_transform(cls)`` / + ``app.add_post_transform(cls)`` call surfaces with its phase) or a + transform-defining module (introspected for ``Transform`` + subclasses). + """ + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for info in discover_transforms(self.arguments[0]): + results.extend(_render_transform(self, info, no_index=no_index)) + return results diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/domain.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/domain.py index c189529c..5e94526e 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/domain.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/domain.py @@ -253,12 +253,12 @@ class DocutilsDomain(Domain): } roles = { # noqa: RUF012 — XRefRole instances are safe to share across domains - TRANSFORM: XRefRole(), - READER: XRefRole(), - PARSER: XRefRole(), - WRITER: XRefRole(), - NODE: XRefRole(), - TRANSLATOR: XRefRole(), + TRANSFORM: XRefRole(warn_dangling=True), + READER: XRefRole(warn_dangling=True), + PARSER: XRefRole(warn_dangling=True), + WRITER: XRefRole(warn_dangling=True), + NODE: XRefRole(warn_dangling=True), + TRANSLATOR: XRefRole(warn_dangling=True), } indices = [ # noqa: RUF012 — matches upstream sphinx.domains.Domain shape diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/domain.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/domain.py index 3a0888c7..7a3f605f 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/domain.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/domain.py @@ -226,8 +226,8 @@ class SphinxExtDomain(Domain): } roles = { # noqa: RUF012 — XRefRole instances are safe to share across domains - BUILDER: XRefRole(), - DOMAIN: XRefRole(), + BUILDER: XRefRole(warn_dangling=True), + DOMAIN: XRefRole(warn_dangling=True), } indices = [ # noqa: RUF012 — matches upstream sphinx.domains.Domain shape diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index 30de0148..a3a27cf1 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -77,6 +77,8 @@ "type", ) +_MANAGED_DOCUTILS_OBJTYPES: tuple[str, ...] = ("transform",) + @dataclasses.dataclass(frozen=True, slots=True) class DescLayoutProfile: @@ -129,6 +131,14 @@ def class_name(self) -> str: slug="mcp-tool", allow_signature_fold=True, ), + **{ + ("docutils", objtype): DescLayoutProfile( + domain="docutils", + objtype=objtype, + slug=f"docutils-{objtype}", + ) + for objtype in _MANAGED_DOCUTILS_OBJTYPES + }, } diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index 948e79fe..82049942 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -162,6 +162,12 @@ class SAB: TYPE_ROLE = "gp-sphinx-badge--type-role" TYPE_OPTION = "gp-sphinx-badge--type-option" + # ── docutils components (filled, per-type hues) ────── + TYPE_TRANSFORM = "gp-sphinx-badge--type-transform" + + # ── docutils component modifiers (outlined) ────────── + MOD_PRIORITY = "gp-sphinx-badge--mod-priority" + # ── Package metadata (maturity + links) ─────────────── META_ALPHA = "gp-sphinx-badge--meta-alpha" META_BETA = "gp-sphinx-badge--meta-beta" diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index db98e575..594530d7 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -115,6 +115,15 @@ --gp-sphinx-badge-docutils-fg: #6d28d9; --gp-sphinx-badge-docutils-border: #8b5cf6; + /* ── docutils components (filled, per-type hues) ────── */ + --gp-sphinx-badge-transform-bg: #faf5ff; + --gp-sphinx-badge-transform-fg: #7e22ce; + --gp-sphinx-badge-transform-border: #a855f7; + + /* ── docutils component modifiers (outlined) ────────── */ + --gp-sphinx-badge-mod-priority-fg: #6b7280; + --gp-sphinx-badge-mod-priority-border: #d1d5db; + /* ── Package metadata (maturity + links) ────────────── */ --gp-sphinx-badge-meta-alpha-bg: #ffedc6; --gp-sphinx-badge-meta-alpha-fg: #4e2009; @@ -223,6 +232,13 @@ --gp-sphinx-badge-docutils-fg: #c4b5fd; --gp-sphinx-badge-docutils-border: #a78bfa; + --gp-sphinx-badge-transform-bg: #3b0764; + --gp-sphinx-badge-transform-fg: #d8b4fe; + --gp-sphinx-badge-transform-border: #c084fc; + + --gp-sphinx-badge-mod-priority-fg: #9ca3af; + --gp-sphinx-badge-mod-priority-border: #4b5563; + --gp-sphinx-badge-meta-alpha-bg: #3f2700; --gp-sphinx-badge-meta-alpha-fg: #ffca16; --gp-sphinx-badge-meta-alpha-border: #8f6424; @@ -331,6 +347,13 @@ body[data-theme="dark"] { --gp-sphinx-badge-docutils-fg: #c4b5fd; --gp-sphinx-badge-docutils-border: #a78bfa; + --gp-sphinx-badge-transform-bg: #3b0764; + --gp-sphinx-badge-transform-fg: #d8b4fe; + --gp-sphinx-badge-transform-border: #c084fc; + + --gp-sphinx-badge-mod-priority-fg: #9ca3af; + --gp-sphinx-badge-mod-priority-border: #4b5563; + --gp-sphinx-badge-meta-alpha-bg: #3f2700; --gp-sphinx-badge-meta-alpha-fg: #ffca16; --gp-sphinx-badge-meta-alpha-border: #8f6424; @@ -398,6 +421,16 @@ body[data-theme="dark"] { .gp-sphinx-badge--type-role, .gp-sphinx-badge--type-option { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-docutils-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-docutils-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-docutils-border); } +/* ══════════════════════════════════════════════════════════ + * Colour classes — docutils components (filled, per-type hues) + * ══════════════════════════════════════════════════════════ */ +.gp-sphinx-badge--type-transform { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-transform-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-transform-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-transform-border); } + +/* ══════════════════════════════════════════════════════════ + * Colour classes — docutils component modifiers (outlined) + * ══════════════════════════════════════════════════════════ */ +.gp-sphinx-badge--mod-priority { --gp-sphinx-badge-fg: var(--gp-sphinx-badge-mod-priority-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-mod-priority-border); } + /* ══════════════════════════════════════════════════════════ * Colour classes — package metadata (maturity + links) * ══════════════════════════════════════════════════════════ */ diff --git a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py index 331c312e..f699400b 100644 --- a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py +++ b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py @@ -21,6 +21,7 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives + from docutils.transforms import Transform class DemoDirective(Directive): @@ -48,6 +49,19 @@ def demo_role( demo_role.options = {"class": directives.class_option} demo_role.content = True + + + class DemoTransform(Transform): + \"\"\"Reorder demo paragraphs after parsing.\"\"\" + + default_priority = 765 + + def apply(self): + pass + + + def setup(app): + app.add_transform(DemoTransform) """ ) @@ -73,6 +87,11 @@ def demo_role( .. autodirective:: demo_docutils_objects.DemoDirective .. autorole:: demo_docutils_objects.demo_role + + .. autotransform:: demo_docutils_objects.DemoTransform + + .. autotransforms:: demo_docutils_objects + :no-index: """ ) @@ -122,3 +141,18 @@ def test_autodoc_docutils_entries_use_shared_layout( assert ">option<" in html assert ">role<" in html assert "Python path" in html + + +@pytest.mark.integration +def test_autodoc_docutils_transform_entries( + autodoc_docutils_html_result: SharedSphinxResult, +) -> None: + """autotransform entries render with profile, badges, and facts.""" + html = read_output(autodoc_docutils_html_result, "index.html") + + assert "gp-sphinx-api-profile--docutils-transform" in html + assert ">transform<" in html + assert "gp-sphinx-badge--mod-priority" in html + assert ">765<" in html + assert "Default priority" in html + assert "app.add_transform()" in html diff --git a/tests/ext/autodoc_docutils/test_components.py b/tests/ext/autodoc_docutils/test_components.py new file mode 100644 index 00000000..6f55a20e --- /dev/null +++ b/tests/ext/autodoc_docutils/test_components.py @@ -0,0 +1,298 @@ +"""Unit tests for the docutils component autodoc pipeline. + +Covers per-type discovery, fact rows, and the shared +``normalize_component_nodes`` / ``inject_component_badges`` doctree +behavior. One file per package; each component type contributes its own +section as it lands. +""" + +from __future__ import annotations + +import typing as t + +import pytest +from docutils import nodes +from docutils.transforms import Transform +from sphinx import addnodes + +from sphinx_autodoc_docutils._badges import build_transform_badge_group +from sphinx_autodoc_docutils._components import ( + component_classes, + component_markup, + import_component, + inject_component_badges, + normalize_component_nodes, +) +from sphinx_autodoc_docutils._transforms_doc import ( + TransformInfo, + _transform_fact_rows, + _transforms_from_calls, + discover_transform, + discover_transforms, +) +from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout._nodes import api_component + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +class _DemoTransform(Transform): + """Demo transform that reorders nothing.""" + + default_priority = 321 + + def apply(self) -> None: + """Do nothing; exists for metadata tests.""" + + +class _BareTransform(Transform): + """Demo transform without an explicit priority.""" + + def apply(self) -> None: + """Do nothing; exists for metadata tests.""" + + +def _make_component_desc( + objtype: str, + *, + name: str = "demo.DemoComponent", +) -> addnodes.desc: + """Build a minimal docutils-domain desc node as Auto* would produce.""" + desc = addnodes.desc(domain="docutils", objtype=objtype) + sig = addnodes.desc_signature(ids=[f"docutils-{objtype}-{name.lower()}"]) + sig += addnodes.desc_name("", name) + desc += sig + content = addnodes.desc_content() + content += nodes.paragraph("", "A demo component for testing.") + desc += content + return desc + + +def _api_facts_child(content: addnodes.desc_content) -> api_component | None: + """Return the gp-sphinx-api-facts component in desc_content, or None.""" + for child in content.children: + if ( + isinstance(child, api_component) + and child.get("name") == "gp-sphinx-api-facts" + ): + return child + return None + + +def _facts_by_label(facts_section: api_component) -> dict[str, str]: + """Return label -> body text for an gp-sphinx-api-facts section.""" + by_label: dict[str, str] = {} + for field in facts_section.findall(nodes.field): + if field.children: + label = field.children[0].astext() + body = field.children[1].astext() if len(field.children) > 1 else "" + by_label[label] = body + return by_label + + +def _demo_fact_rows() -> list[ApiFactRow]: + """Return a small facts list for normalize tests.""" + paragraph = nodes.paragraph() + paragraph += nodes.literal("demo", "demo") + return [ApiFactRow("Python path", paragraph)] + + +# --------------------------------------------------------------------------- +# Shared pipeline +# --------------------------------------------------------------------------- + + +def test_component_markup_renders_domain_directive() -> None: + """component_markup emits a docutils-domain object description.""" + markup = component_markup("transform", "pkg.Sanitize", "Strip stuff.") + assert markup.splitlines()[0] == ".. docutils:transform:: pkg.Sanitize" + assert " Strip stuff." in markup + + +def test_component_markup_default_summary() -> None: + """component_markup falls back to a generic summary.""" + markup = component_markup("writer", "pkg.W", "") + assert "Autodocumented docutils writer." in markup + + +def test_normalize_component_inserts_api_facts_after_summary() -> None: + """normalize_component_nodes inserts gp-sphinx-api-facts after the summary.""" + desc = _make_component_desc("transform") + content = t.cast("addnodes.desc_content", desc.children[-1]) + + normalize_component_nodes( + [desc], + objtype="transform", + fact_rows=_demo_fact_rows(), + ) + + assert isinstance(content.children[0], nodes.paragraph) + facts = _api_facts_child(content) + assert facts is not None, "gp-sphinx-api-facts section should be inserted" + assert "Python path" in _facts_by_label(facts) + + +def test_normalize_component_skips_other_objtypes() -> None: + """normalize_component_nodes leaves non-matching objtypes untouched.""" + transform_desc = _make_component_desc("transform") + writer_desc = _make_component_desc("writer") + + normalize_component_nodes( + [transform_desc, writer_desc], + objtype="transform", + fact_rows=_demo_fact_rows(), + ) + + writer_content = t.cast("addnodes.desc_content", writer_desc.children[-1]) + assert _api_facts_child(writer_content) is None + + +def test_inject_component_badges_marks_signature() -> None: + """inject_component_badges attaches the badge slot exactly once.""" + desc = _make_component_desc("transform") + sig = t.cast("addnodes.desc_signature", desc.children[0]) + + inject_component_badges( + [desc], + objtype="transform", + badge_group=build_transform_badge_group(760), + ) + + assert sig.get("sadoc_badges_injected") is True + + +def test_inject_component_badges_skips_other_objtypes() -> None: + """inject_component_badges leaves non-matching objtypes untouched.""" + desc = _make_component_desc("writer") + sig = t.cast("addnodes.desc_signature", desc.children[0]) + + inject_component_badges( + [desc], + objtype="transform", + badge_group=build_transform_badge_group(None), + ) + + assert sig.get("sadoc_badges_injected") is None + + +def test_import_component_rejects_non_class() -> None: + """import_component raises TypeError for non-class attributes.""" + with pytest.raises(TypeError, match="Expected a class"): + import_component("docutils.transforms.misc.__doc__") + + +# --------------------------------------------------------------------------- +# Transforms +# --------------------------------------------------------------------------- + + +class TransformsFromCallsCase(t.NamedTuple): + """Test case for _transforms_from_calls().""" + + test_id: str + calls: list[tuple[str, tuple[object, ...], dict[str, object]]] + expected: list[tuple[str, str]] + + +_TRANSFORMS_FROM_CALLS_CASES: list[TransformsFromCallsCase] = [ + TransformsFromCallsCase( + test_id="read_phase", + calls=[("add_transform", (_DemoTransform,), {})], + expected=[("_DemoTransform", "add_transform")], + ), + TransformsFromCallsCase( + test_id="post_phase", + calls=[("add_post_transform", (_DemoTransform,), {})], + expected=[("_DemoTransform", "add_post_transform")], + ), + TransformsFromCallsCase( + test_id="ignores_other_calls", + calls=[ + ("add_directive", ("noise", object), {}), + ("add_transform", (_DemoTransform,), {}), + ], + expected=[("_DemoTransform", "add_transform")], + ), + TransformsFromCallsCase( + test_id="ignores_non_transform_classes", + calls=[("add_transform", (object,), {})], + expected=[], + ), + TransformsFromCallsCase( + test_id="dedupes_same_phase", + calls=[ + ("add_transform", (_DemoTransform,), {}), + ("add_transform", (_DemoTransform,), {}), + ], + expected=[("_DemoTransform", "add_transform")], + ), + TransformsFromCallsCase( + test_id="keeps_both_phases", + calls=[ + ("add_transform", (_DemoTransform,), {}), + ("add_post_transform", (_DemoTransform,), {}), + ], + expected=[ + ("_DemoTransform", "add_transform"), + ("_DemoTransform", "add_post_transform"), + ], + ), +] + + +@pytest.mark.parametrize( + "case", + _TRANSFORMS_FROM_CALLS_CASES, + ids=lambda c: c.test_id, +) +def test_transforms_from_calls(case: TransformsFromCallsCase) -> None: + """_transforms_from_calls extracts transform registrations.""" + infos = _transforms_from_calls(case.calls) + assert [(info.cls.__name__, info.registered_via) for info in infos] == case.expected + + +def test_discover_transforms_scan_fallback() -> None: + """discover_transforms scans modules without a registering setup().""" + infos = discover_transforms("docutils.transforms.misc") + names = sorted(info.cls.__name__ for info in infos) + assert names == ["CallBack", "ClassAttribute", "Transitions"] + assert {info.registered_via for info in infos} == {""} + + +def test_discover_transforms_empty_for_module_without_transforms() -> None: + """discover_transforms returns [] for modules without transforms.""" + assert discover_transforms("sphinx_fonts") == [] + + +def test_discover_transform_single_path() -> None: + """discover_transform imports one transform from a dotted path.""" + info = discover_transform("docutils.transforms.misc.Transitions") + assert info.cls.__name__ == "Transitions" + assert info.qualified_name == "docutils.transforms.misc.Transitions" + + +def test_component_classes_excludes_base() -> None: + """component_classes never surfaces the base class itself.""" + classes = component_classes("docutils.transforms", Transform) + assert Transform not in classes + + +def test_transform_fact_rows_with_priority_and_phase() -> None: + """Fact rows surface priority and registration phase.""" + rows = _transform_fact_rows( + TransformInfo(cls=_DemoTransform, registered_via="add_post_transform"), + ) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Python path"].endswith("_DemoTransform") + assert by_label["Default priority"] == "321" + assert by_label["Registered via"] == "app.add_post_transform()" + + +def test_transform_fact_rows_without_priority() -> None: + """A None default_priority renders as an em dash.""" + rows = _transform_fact_rows(TransformInfo(cls=_BareTransform)) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Default priority"] == "—" + assert "Registered via" not in by_label diff --git a/tests/ext/autodoc_docutils/test_domain_xref_integration.py b/tests/ext/autodoc_docutils/test_domain_xref_integration.py new file mode 100644 index 00000000..32437a0a --- /dev/null +++ b/tests/ext/autodoc_docutils/test_domain_xref_integration.py @@ -0,0 +1,164 @@ +"""Integration tests for docutils-domain cross-reference resolution. + +Builds a two-page project: ``index.rst`` documents components (creating +domain targets), ``usage.rst`` cross-references them with +``:docutils:*:`` roles plus one deliberately dangling target so the +tests prove resolution actually runs. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + + from docutils.transforms import Transform + + + class DemoXrefTransform(Transform): + \"\"\"Reorder demo paragraphs for xref tests.\"\"\" + + default_priority = 421 + + def apply(self): + pass + """ +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + import sys + + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "sphinx_autodoc_docutils", + ] + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Component reference + =================== + + .. toctree:: + + usage + + .. autotransform:: demo_xref_components.DemoXrefTransform + """ +) + +_USAGE_RST = textwrap.dedent( + """\ + Usage + ===== + + See :docutils:transform:`DemoXrefTransform` for the short form and + :docutils:transform:`demo_xref_components.DemoXrefTransform` for the + qualified form. + + This one dangles: :docutils:transform:`MissingTransform`. + """ +) + + +@pytest.fixture(scope="module") +def docutils_xref_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build the two-page docutils-domain xref scenario.""" + cache_root = tmp_path_factory.mktemp("autodoc-docutils-xref") + scenario = SphinxScenario( + files=( + ScenarioFile("demo_xref_components.py", _MODULE_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile("index.rst", _INDEX_RST), + ScenarioFile("usage.rst", _USAGE_RST), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("demo_xref_components",), + ) + + +def _xref_warnings(result: SharedSphinxResult) -> list[str]: + """Return xref-resolution warning lines from a build result. + + Filters narrowly for actual resolution failures so unrelated build + noise from earlier in-process Sphinx runs never false-matches. + """ + return [ + line + for line in result.warnings.splitlines() + if "reference target not found" in line.lower() + or "undefined label" in line.lower() + ] + + +@pytest.mark.integration +def test_docutils_xrefs_resolve_without_warnings( + docutils_xref_result: SharedSphinxResult, +) -> None: + """Resolvable :docutils:transform: refs produce no warnings.""" + offending = [ + line + for line in _xref_warnings(docutils_xref_result) + if "MissingTransform" not in line + ] + assert offending == [], "Component cross-references produced warnings:\n" + ( + "\n".join(offending) + ) + + +@pytest.mark.integration +def test_dangling_docutils_xref_warns( + docutils_xref_result: SharedSphinxResult, +) -> None: + """A dangling :docutils:transform: ref warns, proving resolution runs.""" + dangling = [ + line + for line in _xref_warnings(docutils_xref_result) + if "MissingTransform" in line + ] + assert len(dangling) == 1 + + +@pytest.mark.integration +def test_html_contains_resolved_component_links( + docutils_xref_result: SharedSphinxResult, +) -> None: + """Resolved refs become links pointing at the component anchor.""" + usage_html = read_output(docutils_xref_result, "usage.html") + assert 'href="index.html#docutils-transform' in usage_html + + +@pytest.mark.integration +def test_domain_data_populated_after_build( + docutils_xref_result: SharedSphinxResult, +) -> None: + """The documented transform lands in the docutils domain data.""" + domain_data = docutils_xref_result.app.env.domaindata["docutils"] + assert "demo_xref_components.DemoXrefTransform" in domain_data["transform"] From 43666c67a3150ed6a614d3be82254e56798463c3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:02:35 -0500 Subject: [PATCH 04/33] gp-sphinx(autodoc-docutils[autoreader]): Document docutils readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Issue #52 — readers have no Sphinx registration call and are instantiated directly inside publishers (django-docutils), so they were previously undocumentable except via automodule. what: - Add autoreader/autoreaders directives discovering Reader subclasses by module scan; facts surface supported formats, config section, and the get_transforms() set (guarded so framework-dependent readers degrade to a dash instead of breaking the build) - Add safe_transform_names() to the shared component helpers for reader/writer transform-set facts - SAB.TYPE_READER indigo palette + ("docutils", "reader") layout profile; demo reader extending standalone.Reader with the demo transform; examples page sections and reader xref demo --- docs/_ext/docutils_demo_components.py | 16 +++ .../sphinx-autodoc-docutils/examples.md | 22 ++- .../src/sphinx_autodoc_docutils/__init__.py | 12 ++ .../src/sphinx_autodoc_docutils/_badges.py | 1 + .../sphinx_autodoc_docutils/_components.py | 26 ++++ .../sphinx_autodoc_docutils/_readers_doc.py | 125 ++++++++++++++++++ .../sphinx_ux_autodoc_layout/_transforms.py | 2 +- .../src/sphinx_ux_badges/_css.py | 1 + .../_static/css/sab_palettes.css | 13 ++ .../test_autodoc_docutils_integration.py | 27 ++++ tests/ext/autodoc_docutils/test_components.py | 58 ++++++++ 11 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py diff --git a/docs/_ext/docutils_demo_components.py b/docs/_ext/docutils_demo_components.py index b56d4ddb..e4b4a519 100644 --- a/docs/_ext/docutils_demo_components.py +++ b/docs/_ext/docutils_demo_components.py @@ -15,6 +15,7 @@ import typing as t from docutils import nodes +from docutils.readers import standalone from docutils.transforms import Transform if t.TYPE_CHECKING: @@ -40,6 +41,21 @@ def apply(self) -> None: parent.insert(0, paragraph) +class DemoArticleReader(standalone.Reader): # type: ignore[type-arg] + """Read standalone article sources with the demo transform applied. + + Extends the stock standalone reader's transform set with + :class:`DemoReorderTransform` so demo badges surface first. + """ + + supported = ("demo-article",) + config_section = "demo article reader" + + def get_transforms(self) -> list[type[Transform]]: + """Return the standalone transforms plus the demo reorderer.""" + return [*super().get_transforms(), DemoReorderTransform] + + def setup(app: Sphinx) -> ExtensionMetadata: """Register the demo components with Sphinx. diff --git a/docs/packages/sphinx-autodoc-docutils/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md index ad175726..c20ee9a2 100644 --- a/docs/packages/sphinx-autodoc-docutils/examples.md +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -59,12 +59,32 @@ demo module's `app.add_transform()` call: :no-index: ``` +### Document one demo reader + +Readers have no Sphinx registration call, so the single form imports +the class and surfaces its formats, config section, and transform set: + +```{eval-rst} +.. autoreader:: docutils_demo_components.DemoArticleReader +``` + +### Bulk readers demo + +Renders every reader class a module defines: + +```{eval-rst} +.. autoreaders:: docutils_demo_components + :no-index: +``` + ### Cross-referencing components Component entries register targets in the `docutils` domain, so prose can link to them: {docutils:transform}`DemoReorderTransform` resolves to the entry above, and {docutils:transform}`docutils_demo_components.DemoReorderTransform` -spells out the full path. +spells out the full path. Every component type has a matching role — +{docutils:reader}`DemoArticleReader` links the reader entry the same +way. The extension itself registers directives, not docutils roles or Sphinx config values. The generated package reference below lists its registered surface from diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 670c8475..fe2136cb 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -14,6 +14,12 @@ SetupRecorder, replay_setup, ) +from sphinx_autodoc_docutils._readers_doc import ( + AutoReader, + AutoReaders, + discover_reader, + discover_readers, +) from sphinx_autodoc_docutils._transforms_doc import ( AutoTransform, AutoTransforms, @@ -29,6 +35,8 @@ __all__ = [ "AutoDirective", "AutoDirectives", + "AutoReader", + "AutoReaders", "AutoRole", "AutoRoles", "AutoTransform", @@ -37,6 +45,8 @@ "DocutilsDomain", "SetupRecorder", "TransformInfo", + "discover_reader", + "discover_readers", "discover_transform", "discover_transforms", "replay_setup", @@ -93,6 +103,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_directive("autoroles", AutoRoles) app.add_directive("autotransform", AutoTransform) app.add_directive("autotransforms", AutoTransforms) + app.add_directive("autoreader", AutoReader) + app.add_directive("autoreaders", AutoReaders) _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py index 3a6f50b7..ae7d76d3 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py @@ -13,6 +13,7 @@ "role": SAB.TYPE_ROLE, "option": SAB.TYPE_OPTION, "transform": SAB.TYPE_TRANSFORM, + "reader": SAB.TYPE_READER, } diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py index e069fadb..1aa05f9a 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py @@ -214,6 +214,32 @@ def render_component_nodes( return node_list +def safe_transform_names(component_cls: type) -> list[str]: + """Return transform class names from ``cls().get_transforms()``, guarded. + + Readers and writers expose their transform set through + ``get_transforms()`` on an *instance*; real-world components (e.g. + django-docutils) may need framework state to instantiate or to + resolve their transform list, so any failure degrades to ``[]`` + rather than breaking the docs build. + + Examples + -------- + >>> from docutils.readers.standalone import Reader + >>> names = safe_transform_names(Reader) + >>> "Transitions" in names + True + + >>> safe_transform_names(object) + [] + """ + try: + transforms = component_cls().get_transforms() + except Exception: # noqa: BLE001 — degrade to no facts on any component error + return [] + return [cls.__name__ for cls in transforms] + + def import_component(path: str) -> type: """Import one component class from a dotted ``module.ClassName`` path. diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py new file mode 100644 index 00000000..d501fe51 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py @@ -0,0 +1,125 @@ +"""Rendering directives for docutils reader documentation.""" + +from __future__ import annotations + +import typing as t + +from docutils.parsers.rst import directives +from docutils.readers import Reader +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_docutils._badges import build_kind_badge_group +from sphinx_autodoc_docutils._components import ( + component_classes, + import_component, + render_component_nodes, + safe_transform_names, +) +from sphinx_autodoc_docutils._directives import _literal_paragraph, _summary +from sphinx_autodoc_docutils.domain import READER +from sphinx_ux_autodoc_layout import ApiFactRow + +if t.TYPE_CHECKING: + from docutils import nodes + from sphinx.util.typing import OptionSpec + + +def discover_readers(module_name: str) -> list[type[Reader[t.Any]]]: + """Return public reader classes defined in a module. + + Readers have no Sphinx-side registration call, so discovery is a + module subclass scan (django-docutils, for example, instantiates + its reader directly inside a publisher). + + Examples + -------- + >>> readers = discover_readers("docutils.readers.standalone") + >>> [cls.__name__ for cls in readers] + ['Reader'] + + >>> discover_readers("sphinx_fonts") + [] + """ + return component_classes(module_name, Reader) + + +def discover_reader(path: str) -> type[Reader[t.Any]]: + """Return one reader class from a fully-qualified dotted path. + + Examples + -------- + >>> discover_reader("docutils.readers.standalone.Reader").supported + ('standalone',) + """ + return t.cast("type[Reader[t.Any]]", import_component(path)) + + +def _reader_fact_rows(cls: type[Reader[t.Any]]) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented reader. + + Examples + -------- + >>> from docutils.readers.standalone import Reader + >>> rows = _reader_fact_rows(Reader) + >>> [row.label for row in rows] + ['Python path', 'Supported formats', 'Config section', 'Transforms'] + """ + supported = ", ".join(cls.supported) or "—" + transforms = ", ".join(safe_transform_names(cls)) or "—" + return [ + ApiFactRow( + "Python path", + _literal_paragraph(f"{cls.__module__}.{cls.__name__}"), + ), + ApiFactRow("Supported formats", _literal_paragraph(supported)), + ApiFactRow( + "Config section", + _literal_paragraph(cls.config_section or "—"), + ), + ApiFactRow("Transforms", _literal_paragraph(transforms)), + ] + + +def _render_reader( + directive: SphinxDirective, + cls: type[Reader[t.Any]], + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one reader entry through the shared component pipeline.""" + return render_component_nodes( + directive, + objtype=READER, + path=f"{cls.__module__}.{cls.__name__}", + summary=_summary(cls), + fact_rows=_reader_fact_rows(cls), + badge_group=build_kind_badge_group(READER), + no_index=no_index, + ) + + +class AutoReader(SphinxDirective): + """Render documentation for a single reader class.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + cls = discover_reader(self.arguments[0]) + return _render_reader(self, cls, no_index="no-index" in self.options) + + +class AutoReaders(SphinxDirective): + """Render documentation for every reader class a module defines.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for cls in discover_readers(self.arguments[0]): + results.extend(_render_reader(self, cls, no_index=no_index)) + return results diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index a3a27cf1..c233ccda 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -77,7 +77,7 @@ "type", ) -_MANAGED_DOCUTILS_OBJTYPES: tuple[str, ...] = ("transform",) +_MANAGED_DOCUTILS_OBJTYPES: tuple[str, ...] = ("transform", "reader") @dataclasses.dataclass(frozen=True, slots=True) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index 82049942..c5c65e70 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -164,6 +164,7 @@ class SAB: # ── docutils components (filled, per-type hues) ────── TYPE_TRANSFORM = "gp-sphinx-badge--type-transform" + TYPE_READER = "gp-sphinx-badge--type-reader" # ── docutils component modifiers (outlined) ────────── MOD_PRIORITY = "gp-sphinx-badge--mod-priority" diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index 594530d7..6c70d6b4 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -120,6 +120,10 @@ --gp-sphinx-badge-transform-fg: #7e22ce; --gp-sphinx-badge-transform-border: #a855f7; + --gp-sphinx-badge-reader-bg: #eef2ff; + --gp-sphinx-badge-reader-fg: #4338ca; + --gp-sphinx-badge-reader-border: #6366f1; + /* ── docutils component modifiers (outlined) ────────── */ --gp-sphinx-badge-mod-priority-fg: #6b7280; --gp-sphinx-badge-mod-priority-border: #d1d5db; @@ -236,6 +240,10 @@ --gp-sphinx-badge-transform-fg: #d8b4fe; --gp-sphinx-badge-transform-border: #c084fc; + --gp-sphinx-badge-reader-bg: #1e1b4b; + --gp-sphinx-badge-reader-fg: #a5b4fc; + --gp-sphinx-badge-reader-border: #818cf8; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -351,6 +359,10 @@ body[data-theme="dark"] { --gp-sphinx-badge-transform-fg: #d8b4fe; --gp-sphinx-badge-transform-border: #c084fc; + --gp-sphinx-badge-reader-bg: #1e1b4b; + --gp-sphinx-badge-reader-fg: #a5b4fc; + --gp-sphinx-badge-reader-border: #818cf8; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -425,6 +437,7 @@ body[data-theme="dark"] { * Colour classes — docutils components (filled, per-type hues) * ══════════════════════════════════════════════════════════ */ .gp-sphinx-badge--type-transform { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-transform-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-transform-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-transform-border); } +.gp-sphinx-badge--type-reader { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-reader-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-reader-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-reader-border); } /* ══════════════════════════════════════════════════════════ * Colour classes — docutils component modifiers (outlined) diff --git a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py index f699400b..08770063 100644 --- a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py +++ b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py @@ -21,6 +21,7 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives + from docutils.readers import Reader from docutils.transforms import Transform @@ -60,6 +61,16 @@ def apply(self): pass + class DemoReader(Reader): + \"\"\"Read demo article sources.\"\"\" + + supported = ("demo-article",) + config_section = "demo reader" + + def get_transforms(self): + return [*super().get_transforms(), DemoTransform] + + def setup(app): app.add_transform(DemoTransform) """ @@ -92,6 +103,8 @@ def setup(app): .. autotransforms:: demo_docutils_objects :no-index: + + .. autoreader:: demo_docutils_objects.DemoReader """ ) @@ -156,3 +169,17 @@ def test_autodoc_docutils_transform_entries( assert ">765<" in html assert "Default priority" in html assert "app.add_transform()" in html + + +@pytest.mark.integration +def test_autodoc_docutils_reader_entries( + autodoc_docutils_html_result: SharedSphinxResult, +) -> None: + """autoreader entries render with profile, badge, and facts.""" + html = read_output(autodoc_docutils_html_result, "index.html") + + assert "gp-sphinx-api-profile--docutils-reader" in html + assert ">reader<" in html + assert "Supported formats" in html + assert "demo-article" in html + assert "DemoTransform" in html diff --git a/tests/ext/autodoc_docutils/test_components.py b/tests/ext/autodoc_docutils/test_components.py index 6f55a20e..2b4c84c1 100644 --- a/tests/ext/autodoc_docutils/test_components.py +++ b/tests/ext/autodoc_docutils/test_components.py @@ -23,6 +23,11 @@ inject_component_badges, normalize_component_nodes, ) +from sphinx_autodoc_docutils._readers_doc import ( + _reader_fact_rows, + discover_reader, + discover_readers, +) from sphinx_autodoc_docutils._transforms_doc import ( TransformInfo, _transform_fact_rows, @@ -296,3 +301,56 @@ def test_transform_fact_rows_without_priority() -> None: by_label = {row.label: row.body.astext() for row in rows} assert by_label["Default priority"] == "—" assert "Registered via" not in by_label + + +# --------------------------------------------------------------------------- +# Readers +# --------------------------------------------------------------------------- + + +def test_discover_readers_scans_module() -> None: + """discover_readers finds reader subclasses defined in a module.""" + readers = discover_readers("docutils.readers.standalone") + assert [cls.__name__ for cls in readers] == ["Reader"] + + +def test_discover_readers_empty_for_module_without_readers() -> None: + """discover_readers returns [] for modules without readers.""" + assert discover_readers("sphinx_fonts") == [] + + +def test_discover_reader_single_path() -> None: + """discover_reader imports one reader from a dotted path.""" + cls = discover_reader("docutils.readers.standalone.Reader") + assert cls.supported == ("standalone",) + + +def test_reader_fact_rows_surface_formats_and_transforms() -> None: + """Reader fact rows include formats, config section, and transforms.""" + from docutils.readers.standalone import Reader + + rows = _reader_fact_rows(Reader) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Python path"] == "docutils.readers.standalone.Reader" + assert by_label["Supported formats"] == "standalone" + assert by_label["Config section"] == "standalone reader" + assert "Transitions" in by_label["Transforms"] + + +def test_reader_fact_rows_dash_for_empty_metadata() -> None: + """Readers without formats or instantiable transforms degrade to dashes.""" + from docutils.readers import Reader as BaseReader + + class _OpaqueReader(BaseReader): # type: ignore[type-arg] + """Reader whose transform set cannot be resolved.""" + + config_section = "" + + def get_transforms(self) -> list[type]: + raise RuntimeError("needs framework state") + + rows = _reader_fact_rows(_OpaqueReader) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Supported formats"] == "—" + assert by_label["Config section"] == "—" + assert by_label["Transforms"] == "—" From b0d426bc37c5658d9cf3deb4e2cc1fce399ca843 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:05:53 -0500 Subject: [PATCH 05/33] gp-sphinx(autodoc-docutils[autoparser]): Document docutils parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Issue #52 — parsers carry an alias tuple and may be registered as Sphinx source parsers; neither fact was documentable before. what: - Add autoparser/autoparsers directives combining a module subclass scan with recorded app.add_source_parser() calls, so scanned parsers carry their Sphinx registration state and parsers registered from elsewhere still surface - Facts: supported aliases, config section, and the add_source_parser registration when present - SAB.TYPE_PARSER sky palette + ("docutils", "parser") layout profile; demo line parser registered via setup(); examples page sections; unit + integration coverage --- docs/_ext/docutils_demo_components.py | 21 ++ .../sphinx-autodoc-docutils/examples.md | 16 ++ .../src/sphinx_autodoc_docutils/__init__.py | 14 ++ .../src/sphinx_autodoc_docutils/_badges.py | 1 + .../sphinx_autodoc_docutils/_parsers_doc.py | 226 ++++++++++++++++++ .../sphinx_ux_autodoc_layout/_transforms.py | 2 +- .../src/sphinx_ux_badges/_css.py | 1 + .../_static/css/sab_palettes.css | 13 + .../test_autodoc_docutils_integration.py | 28 +++ tests/ext/autodoc_docutils/test_components.py | 68 ++++++ 10 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py diff --git a/docs/_ext/docutils_demo_components.py b/docs/_ext/docutils_demo_components.py index e4b4a519..10e2844a 100644 --- a/docs/_ext/docutils_demo_components.py +++ b/docs/_ext/docutils_demo_components.py @@ -15,6 +15,7 @@ import typing as t from docutils import nodes +from docutils.parsers import Parser from docutils.readers import standalone from docutils.transforms import Transform @@ -56,6 +57,21 @@ def get_transforms(self) -> list[type[Transform]]: return [*super().get_transforms(), DemoReorderTransform] +class DemoLineParser(Parser): + """Parse line-oriented demo sources into one paragraph per line.""" + + supported = ("demo-lines", "demolines") + config_section = "demo line parser" + + def parse(self, inputstring: str, document: nodes.document) -> None: + """Append one paragraph node per non-empty input line.""" + self.setup_parse(inputstring, document) + for line in inputstring.splitlines(): + if line.strip(): + document += nodes.paragraph(text=line.strip()) + self.finish_parse() + + def setup(app: Sphinx) -> ExtensionMetadata: """Register the demo components with Sphinx. @@ -66,14 +82,19 @@ def setup(app: Sphinx) -> ExtensionMetadata: ... self.calls: list[tuple[str, object]] = [] ... def add_transform(self, cls: object) -> None: ... self.calls.append(("add_transform", cls)) + ... def add_source_parser(self, cls: object) -> None: + ... self.calls.append(("add_source_parser", cls)) >>> fake = FakeApp() >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_transform", DemoReorderTransform) in fake.calls True + >>> ("add_source_parser", DemoLineParser) in fake.calls + True >>> metadata["parallel_read_safe"] True """ app.add_transform(DemoReorderTransform) + app.add_source_parser(DemoLineParser) return { "version": "0.0.0", "parallel_read_safe": True, diff --git a/docs/packages/sphinx-autodoc-docutils/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md index c20ee9a2..cc3c5a62 100644 --- a/docs/packages/sphinx-autodoc-docutils/examples.md +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -77,6 +77,22 @@ Renders every reader class a module defines: :no-index: ``` +### Document one demo parser + +Parsers surface their alias tuple and, when the module's `setup()` +calls `app.add_source_parser()`, the Sphinx registration: + +```{eval-rst} +.. autoparser:: docutils_demo_components.DemoLineParser +``` + +### Bulk parsers demo + +```{eval-rst} +.. autoparsers:: docutils_demo_components + :no-index: +``` + ### Cross-referencing components Component entries register targets in the `docutils` domain, so prose diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index fe2136cb..3e058a86 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -14,6 +14,13 @@ SetupRecorder, replay_setup, ) +from sphinx_autodoc_docutils._parsers_doc import ( + AutoParser, + AutoParsers, + ParserInfo, + discover_parser, + discover_parsers, +) from sphinx_autodoc_docutils._readers_doc import ( AutoReader, AutoReaders, @@ -35,6 +42,8 @@ __all__ = [ "AutoDirective", "AutoDirectives", + "AutoParser", + "AutoParsers", "AutoReader", "AutoReaders", "AutoRole", @@ -43,8 +52,11 @@ "AutoTransforms", "DocutilsComponentIndex", "DocutilsDomain", + "ParserInfo", "SetupRecorder", "TransformInfo", + "discover_parser", + "discover_parsers", "discover_reader", "discover_readers", "discover_transform", @@ -105,6 +117,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_directive("autotransforms", AutoTransforms) app.add_directive("autoreader", AutoReader) app.add_directive("autoreaders", AutoReaders) + app.add_directive("autoparser", AutoParser) + app.add_directive("autoparsers", AutoParsers) _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py index ae7d76d3..83256c39 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py @@ -14,6 +14,7 @@ "option": SAB.TYPE_OPTION, "transform": SAB.TYPE_TRANSFORM, "reader": SAB.TYPE_READER, + "parser": SAB.TYPE_PARSER, } diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py new file mode 100644 index 00000000..6ac2f8ce --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py @@ -0,0 +1,226 @@ +"""Rendering directives for docutils parser documentation.""" + +from __future__ import annotations + +import inspect +import typing as t +from dataclasses import dataclass + +from docutils.parsers import Parser +from docutils.parsers.rst import directives +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_docutils._badges import build_kind_badge_group +from sphinx_autodoc_docutils._components import ( + component_classes, + import_component, + render_component_nodes, +) +from sphinx_autodoc_docutils._directives import ( + _literal_paragraph, + _summary, + replay_setup, +) +from sphinx_autodoc_docutils.domain import PARSER +from sphinx_ux_autodoc_layout import ApiFactRow + +if t.TYPE_CHECKING: + from docutils import nodes + from sphinx.util.typing import OptionSpec + + +@dataclass(frozen=True) +class ParserInfo: + """Recorded metadata for one documented parser class. + + Examples + -------- + >>> from docutils.parsers.rst import Parser + >>> info = ParserInfo(cls=Parser, registered_via="add_source_parser") + >>> info.qualified_name + 'docutils.parsers.rst.Parser' + >>> info.aliases[0] + 'rst' + """ + + cls: type[Parser] + registered_via: str = "" + + @property + def qualified_name(self) -> str: + """Return the fully-qualified dotted path for the class. + + Examples + -------- + >>> from docutils.parsers.rst import Parser + >>> ParserInfo(cls=Parser).qualified_name + 'docutils.parsers.rst.Parser' + """ + return f"{self.cls.__module__}.{self.cls.__name__}" + + @property + def aliases(self) -> tuple[str, ...]: + """Return the parser's ``supported`` alias tuple. + + Examples + -------- + >>> from docutils.parsers.rst import Parser + >>> "restructuredtext" in ParserInfo(cls=Parser).aliases + True + """ + return tuple(self.cls.supported) + + +def _source_parsers_from_calls( + calls: list[tuple[str, tuple[object, ...], dict[str, object]]], +) -> list[type[Parser]]: + """Extract parser classes from recorded ``add_source_parser`` calls. + + Examples + -------- + >>> from docutils.parsers.rst import Parser + >>> _source_parsers_from_calls( + ... [ + ... ("add_source_parser", (Parser,), {}), + ... ("add_directive", ("noise", object), {}), + ... ], + ... ) + [] + """ + classes: list[type[Parser]] = [] + for call_name, args, _kwargs in calls: + if call_name != "add_source_parser" or len(args) < 1: + continue + cls = args[0] + if inspect.isclass(cls) and issubclass(cls, Parser) and cls not in classes: + classes.append(cls) + return classes + + +def discover_parsers(module_name: str) -> list[ParserInfo]: + """Return parsers a module defines or registers as source parsers. + + Combines a module subclass scan with the module's recorded + ``app.add_source_parser()`` calls, so scanned classes carry their + Sphinx registration state and parsers registered from elsewhere + still surface. + + Examples + -------- + >>> infos = discover_parsers("docutils.parsers.rst") + >>> [(info.cls.__name__, info.registered_via) for info in infos] + [('Parser', '')] + + >>> discover_parsers("sphinx_fonts") + [] + """ + recorder = replay_setup(module_name) + registered = ( + _source_parsers_from_calls(recorder.calls) if recorder is not None else [] + ) + infos = [ + ParserInfo( + cls=cls, + registered_via="add_source_parser" if cls in registered else "", + ) + for cls in component_classes(module_name, Parser) + ] + scanned = {info.cls for info in infos} + infos.extend( + ParserInfo(cls=cls, registered_via="add_source_parser") + for cls in registered + if cls not in scanned + ) + return infos + + +def discover_parser(path: str) -> ParserInfo: + """Return one parser from a fully-qualified dotted path. + + Examples + -------- + >>> info = discover_parser("docutils.parsers.rst.Parser") + >>> info.cls.__name__ + 'Parser' + """ + cls = t.cast("type[Parser]", import_component(path)) + for info in discover_parsers(cls.__module__): + if info.cls is cls: + return info + return ParserInfo(cls=cls) + + +def _parser_fact_rows(info: ParserInfo) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented parser. + + Examples + -------- + >>> from docutils.parsers.rst import Parser + >>> rows = _parser_fact_rows(ParserInfo(cls=Parser)) + >>> [row.label for row in rows] + ['Python path', 'Supported aliases', 'Config section'] + """ + rows = [ + ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow( + "Supported aliases", + _literal_paragraph(", ".join(info.aliases) or "—"), + ), + ApiFactRow( + "Config section", + _literal_paragraph(info.cls.config_section or "—"), + ), + ] + if info.registered_via: + rows.append( + ApiFactRow( + "Registered via", + _literal_paragraph(f"app.{info.registered_via}()"), + ), + ) + return rows + + +def _render_parser( + directive: SphinxDirective, + info: ParserInfo, + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one parser entry through the shared component pipeline.""" + return render_component_nodes( + directive, + objtype=PARSER, + path=info.qualified_name, + summary=_summary(info.cls), + fact_rows=_parser_fact_rows(info), + badge_group=build_kind_badge_group(PARSER), + no_index=no_index, + ) + + +class AutoParser(SphinxDirective): + """Render documentation for a single parser class.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + info = discover_parser(self.arguments[0]) + return _render_parser(self, info, no_index="no-index" in self.options) + + +class AutoParsers(SphinxDirective): + """Render documentation for every parser a module defines or registers.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for info in discover_parsers(self.arguments[0]): + results.extend(_render_parser(self, info, no_index=no_index)) + return results diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index c233ccda..9f394bb4 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -77,7 +77,7 @@ "type", ) -_MANAGED_DOCUTILS_OBJTYPES: tuple[str, ...] = ("transform", "reader") +_MANAGED_DOCUTILS_OBJTYPES: tuple[str, ...] = ("transform", "reader", "parser") @dataclasses.dataclass(frozen=True, slots=True) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index c5c65e70..33d91c26 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -165,6 +165,7 @@ class SAB: # ── docutils components (filled, per-type hues) ────── TYPE_TRANSFORM = "gp-sphinx-badge--type-transform" TYPE_READER = "gp-sphinx-badge--type-reader" + TYPE_PARSER = "gp-sphinx-badge--type-parser" # ── docutils component modifiers (outlined) ────────── MOD_PRIORITY = "gp-sphinx-badge--mod-priority" diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index 6c70d6b4..ec31666f 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -124,6 +124,10 @@ --gp-sphinx-badge-reader-fg: #4338ca; --gp-sphinx-badge-reader-border: #6366f1; + --gp-sphinx-badge-parser-bg: #f0f9ff; + --gp-sphinx-badge-parser-fg: #0369a1; + --gp-sphinx-badge-parser-border: #0ea5e9; + /* ── docutils component modifiers (outlined) ────────── */ --gp-sphinx-badge-mod-priority-fg: #6b7280; --gp-sphinx-badge-mod-priority-border: #d1d5db; @@ -244,6 +248,10 @@ --gp-sphinx-badge-reader-fg: #a5b4fc; --gp-sphinx-badge-reader-border: #818cf8; + --gp-sphinx-badge-parser-bg: #082f49; + --gp-sphinx-badge-parser-fg: #7dd3fc; + --gp-sphinx-badge-parser-border: #38bdf8; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -363,6 +371,10 @@ body[data-theme="dark"] { --gp-sphinx-badge-reader-fg: #a5b4fc; --gp-sphinx-badge-reader-border: #818cf8; + --gp-sphinx-badge-parser-bg: #082f49; + --gp-sphinx-badge-parser-fg: #7dd3fc; + --gp-sphinx-badge-parser-border: #38bdf8; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -438,6 +450,7 @@ body[data-theme="dark"] { * ══════════════════════════════════════════════════════════ */ .gp-sphinx-badge--type-transform { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-transform-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-transform-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-transform-border); } .gp-sphinx-badge--type-reader { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-reader-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-reader-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-reader-border); } +.gp-sphinx-badge--type-parser { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-parser-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-parser-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-parser-border); } /* ══════════════════════════════════════════════════════════ * Colour classes — docutils component modifiers (outlined) diff --git a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py index 08770063..532905ff 100644 --- a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py +++ b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py @@ -20,6 +20,7 @@ from __future__ import annotations from docutils import nodes + from docutils.parsers import Parser from docutils.parsers.rst import Directive, directives from docutils.readers import Reader from docutils.transforms import Transform @@ -71,8 +72,19 @@ def get_transforms(self): return [*super().get_transforms(), DemoTransform] + class DemoParser(Parser): + \"\"\"Parse demo-line sources.\"\"\" + + supported = ("demo-lines",) + config_section = "demo parser" + + def parse(self, inputstring, document): + pass + + def setup(app): app.add_transform(DemoTransform) + app.add_source_parser(DemoParser) """ ) @@ -105,6 +117,8 @@ def setup(app): :no-index: .. autoreader:: demo_docutils_objects.DemoReader + + .. autoparser:: demo_docutils_objects.DemoParser """ ) @@ -183,3 +197,17 @@ def test_autodoc_docutils_reader_entries( assert "Supported formats" in html assert "demo-article" in html assert "DemoTransform" in html + + +@pytest.mark.integration +def test_autodoc_docutils_parser_entries( + autodoc_docutils_html_result: SharedSphinxResult, +) -> None: + """autoparser entries render with profile, badge, and facts.""" + html = read_output(autodoc_docutils_html_result, "index.html") + + assert "gp-sphinx-api-profile--docutils-parser" in html + assert ">parser<" in html + assert "Supported aliases" in html + assert "demo-lines" in html + assert "app.add_source_parser()" in html diff --git a/tests/ext/autodoc_docutils/test_components.py b/tests/ext/autodoc_docutils/test_components.py index 2b4c84c1..2bb9bd4d 100644 --- a/tests/ext/autodoc_docutils/test_components.py +++ b/tests/ext/autodoc_docutils/test_components.py @@ -23,6 +23,13 @@ inject_component_badges, normalize_component_nodes, ) +from sphinx_autodoc_docutils._parsers_doc import ( + ParserInfo, + _parser_fact_rows, + _source_parsers_from_calls, + discover_parser, + discover_parsers, +) from sphinx_autodoc_docutils._readers_doc import ( _reader_fact_rows, discover_reader, @@ -354,3 +361,64 @@ def get_transforms(self) -> list[type]: assert by_label["Supported formats"] == "—" assert by_label["Config section"] == "—" assert by_label["Transforms"] == "—" + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + + +def test_source_parsers_from_calls_filters_parser_classes() -> None: + """_source_parsers_from_calls keeps only Parser subclasses.""" + from docutils.parsers.rst import Parser as RstParser + + classes = _source_parsers_from_calls( + [ + ("add_source_parser", (RstParser,), {}), + ("add_source_parser", (object,), {}), + ("add_directive", ("noise", object), {}), + ], + ) + assert classes == [RstParser] + + +def test_discover_parsers_scans_module() -> None: + """discover_parsers finds parser subclasses defined in a module.""" + infos = discover_parsers("docutils.parsers.rst") + assert [(info.cls.__name__, info.registered_via) for info in infos] == [ + ("Parser", ""), + ] + + +def test_discover_parsers_empty_for_module_without_parsers() -> None: + """discover_parsers returns [] for modules without parsers.""" + assert discover_parsers("sphinx_fonts") == [] + + +def test_discover_parser_single_path() -> None: + """discover_parser imports one parser from a dotted path.""" + info = discover_parser("docutils.parsers.rst.Parser") + assert "restructuredtext" in info.aliases + + +def test_parser_fact_rows_surface_aliases() -> None: + """Parser fact rows include the alias tuple and config section.""" + from docutils.parsers.rst import Parser as RstParser + + rows = _parser_fact_rows(ParserInfo(cls=RstParser)) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Python path"] == "docutils.parsers.rst.Parser" + assert "rst" in by_label["Supported aliases"] + assert by_label["Config section"] == "restructuredtext parser" + assert "Registered via" not in by_label + + +def test_parser_fact_rows_include_source_parser_registration() -> None: + """A registered source parser surfaces its add_source_parser call.""" + from docutils.parsers.rst import Parser as RstParser + + rows = _parser_fact_rows( + ParserInfo(cls=RstParser, registered_via="add_source_parser"), + ) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Registered via"] == "app.add_source_parser()" From 6608ceb5627fc4c521f7e13f5fe5aab8aee08a35 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:10:50 -0500 Subject: [PATCH 06/33] gp-sphinx(autodoc-docutils[autowriter]): Document docutils writers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Issue #52 — writers carry output formats and a translator class, but the translator is often assigned in __init__ rather than as a class attribute (django-docutils), making it invisible to naive introspection. what: - Add autowriter/autowriters directives discovering Writer subclasses by module scan; facts surface supported formats, the resolved translator class, config section, and the get_transforms() set - Add resolve_translator_class(): instantiate the writer to read the instance attribute, fall back to the class attribute when construction needs framework state - SAB.TYPE_WRITER teal palette + ("docutils", "writer") layout profile; demo plain-text writer assigning its translator in __init__ to exercise the defensive path; examples page sections --- docs/_ext/docutils_demo_components.py | 53 ++++++ .../sphinx-autodoc-docutils/examples.md | 17 ++ .../src/sphinx_autodoc_docutils/__init__.py | 12 ++ .../src/sphinx_autodoc_docutils/_badges.py | 1 + .../sphinx_autodoc_docutils/_writers_doc.py | 161 ++++++++++++++++++ .../sphinx_ux_autodoc_layout/_transforms.py | 7 +- .../src/sphinx_ux_badges/_css.py | 1 + .../_static/css/sab_palettes.css | 13 ++ .../test_autodoc_docutils_integration.py | 38 +++++ tests/ext/autodoc_docutils/test_components.py | 77 +++++++++ 10 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py diff --git a/docs/_ext/docutils_demo_components.py b/docs/_ext/docutils_demo_components.py index 10e2844a..30b0520e 100644 --- a/docs/_ext/docutils_demo_components.py +++ b/docs/_ext/docutils_demo_components.py @@ -18,6 +18,7 @@ from docutils.parsers import Parser from docutils.readers import standalone from docutils.transforms import Transform +from docutils.writers import Writer if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -72,6 +73,58 @@ def parse(self, inputstring: str, document: nodes.document) -> None: self.finish_parse() +class DemoTextTranslator(nodes.NodeVisitor): + """Translate paragraphs into plain text lines for the demo writer.""" + + def __init__(self, document: nodes.document) -> None: + super().__init__(document) + self.lines: list[str] = [] + + def visit_paragraph(self, node: nodes.paragraph) -> None: + """Open a fresh output line.""" + self.lines.append("") + + def depart_paragraph(self, node: nodes.paragraph) -> None: + """Close the current output line.""" + + def visit_Text(self, node: nodes.Text) -> None: + """Append literal text to the current line.""" + if self.lines: + self.lines[-1] += node.astext() + + def unknown_visit(self, node: nodes.Node) -> None: + """Ignore nodes the demo writer does not understand.""" + + def unknown_departure(self, node: nodes.Node) -> None: + """Ignore nodes the demo writer does not understand.""" + + +class DemoPlainWriter(Writer): # type: ignore[type-arg] + """Write documents as plain text lines, one paragraph per line. + + Assigns ``translator_class`` in ``__init__`` (the django-docutils + style) rather than as a class attribute, which exercises the + defensive resolution the ``autowriter`` directive performs. + """ + + supported = ("demo-plain",) + config_section = "demo plain writer" + + def __init__(self) -> None: + super().__init__() + self.translator_class = DemoTextTranslator + + def translate(self) -> None: + """Visit the document and join the collected lines.""" + document = self.document + if document is None: + self.output = "" + return + visitor = DemoTextTranslator(document) + document.walkabout(visitor) + self.output = "\n".join(visitor.lines) + + def setup(app: Sphinx) -> ExtensionMetadata: """Register the demo components with Sphinx. diff --git a/docs/packages/sphinx-autodoc-docutils/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md index cc3c5a62..2f0e6cc2 100644 --- a/docs/packages/sphinx-autodoc-docutils/examples.md +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -93,6 +93,23 @@ calls `app.add_source_parser()`, the Sphinx registration: :no-index: ``` +### Document one demo writer + +Writers surface their output formats and translator class — resolved +defensively, since writers commonly assign `translator_class` inside +`__init__`: + +```{eval-rst} +.. autowriter:: docutils_demo_components.DemoPlainWriter +``` + +### Bulk writers demo + +```{eval-rst} +.. autowriters:: docutils_demo_components + :no-index: +``` + ### Cross-referencing components Component entries register targets in the `docutils` domain, so prose diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 3e058a86..62137bc3 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -34,6 +34,12 @@ discover_transform, discover_transforms, ) +from sphinx_autodoc_docutils._writers_doc import ( + AutoWriter, + AutoWriters, + discover_writer, + discover_writers, +) from sphinx_autodoc_docutils.domain import ( DocutilsComponentIndex, DocutilsDomain, @@ -50,6 +56,8 @@ "AutoRoles", "AutoTransform", "AutoTransforms", + "AutoWriter", + "AutoWriters", "DocutilsComponentIndex", "DocutilsDomain", "ParserInfo", @@ -61,6 +69,8 @@ "discover_readers", "discover_transform", "discover_transforms", + "discover_writer", + "discover_writers", "replay_setup", "setup", ] @@ -119,6 +129,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_directive("autoreaders", AutoReaders) app.add_directive("autoparser", AutoParser) app.add_directive("autoparsers", AutoParsers) + app.add_directive("autowriter", AutoWriter) + app.add_directive("autowriters", AutoWriters) _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py index 83256c39..8a0a9090 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py @@ -15,6 +15,7 @@ "transform": SAB.TYPE_TRANSFORM, "reader": SAB.TYPE_READER, "parser": SAB.TYPE_PARSER, + "writer": SAB.TYPE_WRITER, } diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py new file mode 100644 index 00000000..69c1280d --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py @@ -0,0 +1,161 @@ +"""Rendering directives for docutils writer documentation.""" + +from __future__ import annotations + +import inspect +import typing as t + +from docutils.parsers.rst import directives +from docutils.writers import Writer +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_docutils._badges import build_kind_badge_group +from sphinx_autodoc_docutils._components import ( + component_classes, + import_component, + render_component_nodes, + safe_transform_names, +) +from sphinx_autodoc_docutils._directives import _literal_paragraph, _summary +from sphinx_autodoc_docutils.domain import WRITER +from sphinx_ux_autodoc_layout import ApiFactRow + +if t.TYPE_CHECKING: + from docutils import nodes + from sphinx.util.typing import OptionSpec + + +def discover_writers(module_name: str) -> list[type[Writer[t.Any]]]: + """Return public writer classes defined in a module. + + Writers have no Sphinx-side registration call, so discovery is a + module subclass scan (django-docutils instantiates its writer + directly inside a publisher). + + Examples + -------- + >>> writers = discover_writers("docutils.writers.html5_polyglot") + >>> [cls.__name__ for cls in writers] + ['Writer'] + + >>> discover_writers("sphinx_fonts") + [] + """ + return component_classes(module_name, Writer) + + +def discover_writer(path: str) -> type[Writer[t.Any]]: + """Return one writer class from a fully-qualified dotted path. + + Examples + -------- + >>> discover_writer("docutils.writers.html5_polyglot.Writer").supported + ('html5', 'xhtml', 'html') + """ + return t.cast("type[Writer[t.Any]]", import_component(path)) + + +def resolve_translator_class(cls: type[Writer[t.Any]]) -> type | None: + """Return the writer's translator class, instantiating defensively. + + Writers commonly assign ``translator_class`` in ``__init__`` rather + than as a class attribute (django-docutils does), so instantiate + first and fall back to the class attribute when construction needs + framework state. + + Examples + -------- + >>> from docutils.writers import html5_polyglot + >>> resolve_translator_class(html5_polyglot.Writer).__name__ + 'HTMLTranslator' + """ + translator: object + try: + translator = getattr(cls(), "translator_class", None) + except Exception: # noqa: BLE001 — degrade to the class attribute on any error + translator = getattr(cls, "translator_class", None) + if inspect.isclass(translator): + return translator + return None + + +def _writer_fact_rows(cls: type[Writer[t.Any]]) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented writer. + + Examples + -------- + >>> from docutils.writers import html5_polyglot + >>> rows = _writer_fact_rows(html5_polyglot.Writer) + >>> [row.label for row in rows] + ['Python path', 'Supported formats', 'Translator class', 'Config section', 'Transforms'] + """ + translator = resolve_translator_class(cls) + translator_path = ( + f"{translator.__module__}.{translator.__name__}" + if translator is not None + else "—" + ) + return [ + ApiFactRow( + "Python path", + _literal_paragraph(f"{cls.__module__}.{cls.__name__}"), + ), + ApiFactRow( + "Supported formats", + _literal_paragraph(", ".join(cls.supported) or "—"), + ), + ApiFactRow("Translator class", _literal_paragraph(translator_path)), + ApiFactRow( + "Config section", + _literal_paragraph(cls.config_section or "—"), + ), + ApiFactRow( + "Transforms", + _literal_paragraph(", ".join(safe_transform_names(cls)) or "—"), + ), + ] + + +def _render_writer( + directive: SphinxDirective, + cls: type[Writer[t.Any]], + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one writer entry through the shared component pipeline.""" + return render_component_nodes( + directive, + objtype=WRITER, + path=f"{cls.__module__}.{cls.__name__}", + summary=_summary(cls), + fact_rows=_writer_fact_rows(cls), + badge_group=build_kind_badge_group(WRITER), + no_index=no_index, + ) + + +class AutoWriter(SphinxDirective): + """Render documentation for a single writer class.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + cls = discover_writer(self.arguments[0]) + return _render_writer(self, cls, no_index="no-index" in self.options) + + +class AutoWriters(SphinxDirective): + """Render documentation for every writer class a module defines.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for cls in discover_writers(self.arguments[0]): + results.extend(_render_writer(self, cls, no_index=no_index)) + return results diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index 9f394bb4..9e88fda4 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -77,7 +77,12 @@ "type", ) -_MANAGED_DOCUTILS_OBJTYPES: tuple[str, ...] = ("transform", "reader", "parser") +_MANAGED_DOCUTILS_OBJTYPES: tuple[str, ...] = ( + "transform", + "reader", + "parser", + "writer", +) @dataclasses.dataclass(frozen=True, slots=True) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index 33d91c26..8593dacd 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -166,6 +166,7 @@ class SAB: TYPE_TRANSFORM = "gp-sphinx-badge--type-transform" TYPE_READER = "gp-sphinx-badge--type-reader" TYPE_PARSER = "gp-sphinx-badge--type-parser" + TYPE_WRITER = "gp-sphinx-badge--type-writer" # ── docutils component modifiers (outlined) ────────── MOD_PRIORITY = "gp-sphinx-badge--mod-priority" diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index ec31666f..6a26b218 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -128,6 +128,10 @@ --gp-sphinx-badge-parser-fg: #0369a1; --gp-sphinx-badge-parser-border: #0ea5e9; + --gp-sphinx-badge-writer-bg: #f0fdfa; + --gp-sphinx-badge-writer-fg: #0f766e; + --gp-sphinx-badge-writer-border: #14b8a6; + /* ── docutils component modifiers (outlined) ────────── */ --gp-sphinx-badge-mod-priority-fg: #6b7280; --gp-sphinx-badge-mod-priority-border: #d1d5db; @@ -252,6 +256,10 @@ --gp-sphinx-badge-parser-fg: #7dd3fc; --gp-sphinx-badge-parser-border: #38bdf8; + --gp-sphinx-badge-writer-bg: #042f2e; + --gp-sphinx-badge-writer-fg: #5eead4; + --gp-sphinx-badge-writer-border: #2dd4bf; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -375,6 +383,10 @@ body[data-theme="dark"] { --gp-sphinx-badge-parser-fg: #7dd3fc; --gp-sphinx-badge-parser-border: #38bdf8; + --gp-sphinx-badge-writer-bg: #042f2e; + --gp-sphinx-badge-writer-fg: #5eead4; + --gp-sphinx-badge-writer-border: #2dd4bf; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -451,6 +463,7 @@ body[data-theme="dark"] { .gp-sphinx-badge--type-transform { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-transform-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-transform-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-transform-border); } .gp-sphinx-badge--type-reader { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-reader-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-reader-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-reader-border); } .gp-sphinx-badge--type-parser { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-parser-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-parser-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-parser-border); } +.gp-sphinx-badge--type-writer { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-writer-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-writer-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-writer-border); } /* ══════════════════════════════════════════════════════════ * Colour classes — docutils component modifiers (outlined) diff --git a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py index 532905ff..6001f24d 100644 --- a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py +++ b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py @@ -24,6 +24,7 @@ from docutils.parsers.rst import Directive, directives from docutils.readers import Reader from docutils.transforms import Transform + from docutils.writers import Writer class DemoDirective(Directive): @@ -82,6 +83,27 @@ def parse(self, inputstring, document): pass + class DemoTranslator(nodes.SparseNodeVisitor): + \"\"\"Translate demo documents to plain text.\"\"\" + + def visit_paragraph(self, node): + pass + + def depart_paragraph(self, node): + pass + + + class DemoWriter(Writer): + \"\"\"Write demo documents as plain text.\"\"\" + + supported = ("demo-plain",) + config_section = "demo writer" + translator_class = DemoTranslator + + def translate(self): + self.output = "" + + def setup(app): app.add_transform(DemoTransform) app.add_source_parser(DemoParser) @@ -119,6 +141,8 @@ def setup(app): .. autoreader:: demo_docutils_objects.DemoReader .. autoparser:: demo_docutils_objects.DemoParser + + .. autowriter:: demo_docutils_objects.DemoWriter """ ) @@ -211,3 +235,17 @@ def test_autodoc_docutils_parser_entries( assert "Supported aliases" in html assert "demo-lines" in html assert "app.add_source_parser()" in html + + +@pytest.mark.integration +def test_autodoc_docutils_writer_entries( + autodoc_docutils_html_result: SharedSphinxResult, +) -> None: + """autowriter entries render with profile, badge, and facts.""" + html = read_output(autodoc_docutils_html_result, "index.html") + + assert "gp-sphinx-api-profile--docutils-writer" in html + assert ">writer<" in html + assert "Translator class" in html + assert "demo_docutils_objects.DemoTranslator" in html + assert "demo-plain" in html diff --git a/tests/ext/autodoc_docutils/test_components.py b/tests/ext/autodoc_docutils/test_components.py index 2bb9bd4d..5ab07bd7 100644 --- a/tests/ext/autodoc_docutils/test_components.py +++ b/tests/ext/autodoc_docutils/test_components.py @@ -42,6 +42,12 @@ discover_transform, discover_transforms, ) +from sphinx_autodoc_docutils._writers_doc import ( + _writer_fact_rows, + discover_writer, + discover_writers, + resolve_translator_class, +) from sphinx_ux_autodoc_layout import ApiFactRow from sphinx_ux_autodoc_layout._nodes import api_component @@ -422,3 +428,74 @@ def test_parser_fact_rows_include_source_parser_registration() -> None: ) by_label = {row.label: row.body.astext() for row in rows} assert by_label["Registered via"] == "app.add_source_parser()" + + +# --------------------------------------------------------------------------- +# Writers +# --------------------------------------------------------------------------- + + +def test_discover_writers_scans_module() -> None: + """discover_writers finds writer subclasses defined in a module.""" + writers = discover_writers("docutils.writers.html5_polyglot") + assert [cls.__name__ for cls in writers] == ["Writer"] + + +def test_discover_writers_empty_for_module_without_writers() -> None: + """discover_writers returns [] for modules without writers.""" + assert discover_writers("sphinx_fonts") == [] + + +def test_discover_writer_single_path() -> None: + """discover_writer imports one writer from a dotted path.""" + cls = discover_writer("docutils.writers.html5_polyglot.Writer") + assert "html5" in cls.supported + + +def test_resolve_translator_class_from_init_assignment() -> None: + """Writers assigning translator_class in __init__ still resolve.""" + from docutils.writers import Writer as BaseWriter + + class _InitWriter(BaseWriter): # type: ignore[type-arg] + """Writer assigning its translator at construction time.""" + + def __init__(self) -> None: + super().__init__() + self.translator_class = nodes.SparseNodeVisitor + + def translate(self) -> None: + self.output = "" + + assert resolve_translator_class(_InitWriter) is nodes.SparseNodeVisitor + + +def test_resolve_translator_class_falls_back_to_class_attr() -> None: + """Writers that raise on construction fall back to the class attribute.""" + from docutils.writers import Writer as BaseWriter + + class _FussyWriter(BaseWriter): # type: ignore[type-arg] + """Writer that needs framework state to construct.""" + + translator_class = nodes.SparseNodeVisitor + + def __init__(self) -> None: + raise RuntimeError("needs framework state") + + def translate(self) -> None: + self.output = "" + + assert resolve_translator_class(_FussyWriter) is nodes.SparseNodeVisitor + + +def test_writer_fact_rows_surface_formats_and_translator() -> None: + """Writer fact rows include formats, translator path, and transforms.""" + from docutils.writers import html5_polyglot + + rows = _writer_fact_rows(html5_polyglot.Writer) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Python path"] == "docutils.writers.html5_polyglot.Writer" + assert "html5" in by_label["Supported formats"] + assert by_label["Translator class"] == ( + "docutils.writers.html5_polyglot.HTMLTranslator" + ) + assert by_label["Transforms"] != "" From cfb38f86b781cde2383e870d97928540237bedf7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:15:53 -0500 Subject: [PATCH 07/33] gp-sphinx(autodoc-docutils[autonode]): Document custom docutils nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Issue #52 — custom node classes carry the richest hidden metadata: element category mixins and per-builder visit/depart handlers registered through app.add_node(). django-docutils' icon node is handled purely by a custom translator with no add_node call at all, so discovery must work without registration too. what: - Add autonode/autonodes directives combining a module Element subclass scan with recorded app.add_node() calls; handler kwargs map builder names to (visit, depart) tuples, the override keyword is skipped, and repeated registrations follow override semantics - Facts: base classes, docutils element categories (Root through Inline, via issubclass against the category mixins), and the builders handlers were registered for - SAB.TYPE_NODE fuchsia palette + ("docutils", "node") layout profile; demo_marker inline node with html handlers; examples page sections; recorder-shape NamedTuple test matrix --- docs/_ext/docutils_demo_components.py | 19 ++ .../sphinx-autodoc-docutils/examples.md | 17 ++ .../src/sphinx_autodoc_docutils/__init__.py | 14 + .../src/sphinx_autodoc_docutils/_badges.py | 1 + .../src/sphinx_autodoc_docutils/_nodes_doc.py | 246 ++++++++++++++++++ .../sphinx_ux_autodoc_layout/_transforms.py | 1 + .../src/sphinx_ux_badges/_css.py | 1 + .../_static/css/sab_palettes.css | 13 + .../test_autodoc_docutils_integration.py | 29 +++ tests/ext/autodoc_docutils/test_components.py | 142 ++++++++++ 10 files changed, 483 insertions(+) create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py diff --git a/docs/_ext/docutils_demo_components.py b/docs/_ext/docutils_demo_components.py index 30b0520e..1486ddf4 100644 --- a/docs/_ext/docutils_demo_components.py +++ b/docs/_ext/docutils_demo_components.py @@ -73,6 +73,20 @@ def parse(self, inputstring: str, document: nodes.document) -> None: self.finish_parse() +class demo_marker(nodes.General, nodes.Inline, nodes.Element): + """Inline marker node rendered as a highlighted ```` span.""" + + +def visit_demo_marker(translator: nodes.NodeVisitor, node: demo_marker) -> None: + """Open the ```` wrapper for a demo marker node.""" + translator.body.append("") # type: ignore[attr-defined] + + +def depart_demo_marker(translator: nodes.NodeVisitor, node: demo_marker) -> None: + """Close the ```` wrapper for a demo marker node.""" + translator.body.append("") # type: ignore[attr-defined] + + class DemoTextTranslator(nodes.NodeVisitor): """Translate paragraphs into plain text lines for the demo writer.""" @@ -137,17 +151,22 @@ def setup(app: Sphinx) -> ExtensionMetadata: ... self.calls.append(("add_transform", cls)) ... def add_source_parser(self, cls: object) -> None: ... self.calls.append(("add_source_parser", cls)) + ... def add_node(self, cls: object, **kwargs: object) -> None: + ... self.calls.append(("add_node", cls)) >>> fake = FakeApp() >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_transform", DemoReorderTransform) in fake.calls True >>> ("add_source_parser", DemoLineParser) in fake.calls True + >>> ("add_node", demo_marker) in fake.calls + True >>> metadata["parallel_read_safe"] True """ app.add_transform(DemoReorderTransform) app.add_source_parser(DemoLineParser) + app.add_node(demo_marker, html=(visit_demo_marker, depart_demo_marker)) return { "version": "0.0.0", "parallel_read_safe": True, diff --git a/docs/packages/sphinx-autodoc-docutils/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md index 2f0e6cc2..e77df271 100644 --- a/docs/packages/sphinx-autodoc-docutils/examples.md +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -110,6 +110,23 @@ defensively, since writers commonly assign `translator_class` inside :no-index: ``` +### Document one demo node + +Custom node classes surface their base classes, docutils element +categories, and the builders their visit/depart handlers were +registered for via `app.add_node()`: + +```{eval-rst} +.. autonode:: docutils_demo_components.demo_marker +``` + +### Bulk nodes demo + +```{eval-rst} +.. autonodes:: docutils_demo_components + :no-index: +``` + ### Cross-referencing components Component entries register targets in the `docutils` domain, so prose diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 62137bc3..90f0b0d4 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -14,6 +14,13 @@ SetupRecorder, replay_setup, ) +from sphinx_autodoc_docutils._nodes_doc import ( + AutoNode, + AutoNodes, + NodeInfo, + discover_node, + discover_nodes, +) from sphinx_autodoc_docutils._parsers_doc import ( AutoParser, AutoParsers, @@ -48,6 +55,8 @@ __all__ = [ "AutoDirective", "AutoDirectives", + "AutoNode", + "AutoNodes", "AutoParser", "AutoParsers", "AutoReader", @@ -60,9 +69,12 @@ "AutoWriters", "DocutilsComponentIndex", "DocutilsDomain", + "NodeInfo", "ParserInfo", "SetupRecorder", "TransformInfo", + "discover_node", + "discover_nodes", "discover_parser", "discover_parsers", "discover_reader", @@ -131,6 +143,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_directive("autoparsers", AutoParsers) app.add_directive("autowriter", AutoWriter) app.add_directive("autowriters", AutoWriters) + app.add_directive("autonode", AutoNode) + app.add_directive("autonodes", AutoNodes) _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py index 8a0a9090..f33ade24 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py @@ -16,6 +16,7 @@ "reader": SAB.TYPE_READER, "parser": SAB.TYPE_PARSER, "writer": SAB.TYPE_WRITER, + "node": SAB.TYPE_NODE, } diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py new file mode 100644 index 00000000..2b3e0b0c --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py @@ -0,0 +1,246 @@ +"""Rendering directives for custom docutils node documentation.""" + +from __future__ import annotations + +import inspect +import typing as t +from dataclasses import dataclass + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_docutils._badges import build_kind_badge_group +from sphinx_autodoc_docutils._components import ( + component_classes, + import_component, + render_component_nodes, +) +from sphinx_autodoc_docutils._directives import ( + _literal_paragraph, + _summary, + replay_setup, +) +from sphinx_autodoc_docutils.domain import NODE +from sphinx_ux_autodoc_layout import ApiFactRow + +if t.TYPE_CHECKING: + from sphinx.util.typing import OptionSpec + +#: docutils element category mixins surfaced as a fact row, in +#: ``docutils.nodes`` declaration order. +_CATEGORY_MIXINS: tuple[str, ...] = ( + "Root", + "Titular", + "PreBibliographic", + "Bibliographic", + "Decorative", + "Structural", + "Body", + "General", + "Sequential", + "Admonition", + "Special", + "Invisible", + "Part", + "Inline", +) + + +@dataclass(frozen=True) +class NodeInfo: + """Recorded metadata for one documented node class. + + Examples + -------- + >>> from sphinx_ux_badges import BadgeNode + >>> info = NodeInfo(cls=BadgeNode, handlers=("html",)) + >>> info.qualified_name + 'sphinx_ux_badges._nodes.BadgeNode' + >>> info.handlers + ('html',) + """ + + cls: type[nodes.Element] + handlers: tuple[str, ...] = () + + @property + def qualified_name(self) -> str: + """Return the fully-qualified dotted path for the class. + + Examples + -------- + >>> NodeInfo(cls=nodes.paragraph).qualified_name + 'docutils.nodes.paragraph' + """ + return f"{self.cls.__module__}.{self.cls.__name__}" + + +def node_categories(cls: type[nodes.Element]) -> list[str]: + """Return the docutils element categories a node class mixes in. + + Examples + -------- + >>> node_categories(nodes.image) + ['Body', 'General', 'Inline'] + >>> node_categories(nodes.note) + ['Body', 'Admonition'] + """ + return [ + category + for category in _CATEGORY_MIXINS + if issubclass(cls, getattr(nodes, category)) + ] + + +def _nodes_from_calls( + calls: list[tuple[str, tuple[object, ...], dict[str, object]]], +) -> list[NodeInfo]: + """Extract node metadata from recorded ``add_node`` calls. + + Handler keyword arguments map builder names to ``(visit, depart)`` + tuples; the ``override`` keyword is registration plumbing, not a + handler, and is skipped. A repeated registration for the same class + wins (override semantics). + + Examples + -------- + >>> def _visit(self, node): ... + >>> infos = _nodes_from_calls( + ... [ + ... ( + ... "add_node", + ... (nodes.paragraph,), + ... {"override": True, "html": (_visit, None)}, + ... ), + ... ], + ... ) + >>> [(info.cls.__name__, info.handlers) for info in infos] + [('paragraph', ('html',))] + """ + by_cls: dict[type[nodes.Element], NodeInfo] = {} + for call_name, args, kwargs in calls: + if call_name != "add_node" or len(args) < 1: + continue + cls = args[0] + if not (inspect.isclass(cls) and issubclass(cls, nodes.Element)): + continue + handlers = tuple( + key + for key, value in kwargs.items() + if key != "override" and isinstance(value, tuple) + ) + by_cls[cls] = NodeInfo(cls=cls, handlers=handlers) + return list(by_cls.values()) + + +def discover_nodes(module_name: str) -> list[NodeInfo]: + """Return node classes a module defines or registers. + + Combines a module subclass scan with the module's recorded + ``app.add_node()`` calls, so scanned classes carry their visit / + depart handler builders and nodes registered from submodules still + surface. Nodes handled purely by a custom translator (the + django-docutils ``icon`` pattern, with no ``add_node`` call at all) + are found by the scan with no handlers. + + Examples + -------- + >>> infos = discover_nodes("sphinx_ux_badges") + >>> [(info.cls.__name__, info.handlers) for info in infos] + [('BadgeNode', ('html',))] + + >>> discover_nodes("sphinx_fonts") + [] + """ + recorder = replay_setup(module_name) + registered = _nodes_from_calls(recorder.calls) if recorder is not None else [] + by_cls = {info.cls: info for info in registered} + infos = [ + by_cls.get(cls, NodeInfo(cls=cls)) + for cls in component_classes(module_name, nodes.Element) + ] + scanned = {info.cls for info in infos} + infos.extend(info for info in registered if info.cls not in scanned) + return infos + + +def discover_node(path: str) -> NodeInfo: + """Return one node class from a fully-qualified dotted path. + + Examples + -------- + >>> info = discover_node("sphinx_ux_badges.BadgeNode") + >>> info.cls.__name__ + 'BadgeNode' + """ + cls = t.cast("type[nodes.Element]", import_component(path)) + for info in discover_nodes(cls.__module__): + if info.cls is cls: + return info + return NodeInfo(cls=cls) + + +def _node_fact_rows(info: NodeInfo) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented node. + + Examples + -------- + >>> rows = _node_fact_rows(NodeInfo(cls=nodes.image, handlers=("html",))) + >>> [row.label for row in rows] + ['Python path', 'Base classes', 'Categories', 'Visit/depart handlers'] + """ + bases = ", ".join(base.__name__ for base in info.cls.__bases__) + categories = ", ".join(node_categories(info.cls)) or "—" + handlers = ", ".join(info.handlers) or "—" + return [ + ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow("Base classes", _literal_paragraph(bases)), + ApiFactRow("Categories", _literal_paragraph(categories)), + ApiFactRow("Visit/depart handlers", _literal_paragraph(handlers)), + ] + + +def _render_node( + directive: SphinxDirective, + info: NodeInfo, + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one node entry through the shared component pipeline.""" + return render_component_nodes( + directive, + objtype=NODE, + path=info.qualified_name, + summary=_summary(info.cls), + fact_rows=_node_fact_rows(info), + badge_group=build_kind_badge_group(NODE), + no_index=no_index, + ) + + +class AutoNode(SphinxDirective): + """Render documentation for a single node class.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + info = discover_node(self.arguments[0]) + return _render_node(self, info, no_index="no-index" in self.options) + + +class AutoNodes(SphinxDirective): + """Render documentation for every node a module defines or registers.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for info in discover_nodes(self.arguments[0]): + results.extend(_render_node(self, info, no_index=no_index)) + return results diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index 9e88fda4..fda9b552 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -82,6 +82,7 @@ "reader", "parser", "writer", + "node", ) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index 8593dacd..8cb5063f 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -167,6 +167,7 @@ class SAB: TYPE_READER = "gp-sphinx-badge--type-reader" TYPE_PARSER = "gp-sphinx-badge--type-parser" TYPE_WRITER = "gp-sphinx-badge--type-writer" + TYPE_NODE = "gp-sphinx-badge--type-node" # ── docutils component modifiers (outlined) ────────── MOD_PRIORITY = "gp-sphinx-badge--mod-priority" diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index 6a26b218..4a9166ce 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -132,6 +132,10 @@ --gp-sphinx-badge-writer-fg: #0f766e; --gp-sphinx-badge-writer-border: #14b8a6; + --gp-sphinx-badge-node-bg: #fdf4ff; + --gp-sphinx-badge-node-fg: #a21caf; + --gp-sphinx-badge-node-border: #d946ef; + /* ── docutils component modifiers (outlined) ────────── */ --gp-sphinx-badge-mod-priority-fg: #6b7280; --gp-sphinx-badge-mod-priority-border: #d1d5db; @@ -260,6 +264,10 @@ --gp-sphinx-badge-writer-fg: #5eead4; --gp-sphinx-badge-writer-border: #2dd4bf; + --gp-sphinx-badge-node-bg: #4a044e; + --gp-sphinx-badge-node-fg: #f0abfc; + --gp-sphinx-badge-node-border: #e879f9; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -387,6 +395,10 @@ body[data-theme="dark"] { --gp-sphinx-badge-writer-fg: #5eead4; --gp-sphinx-badge-writer-border: #2dd4bf; + --gp-sphinx-badge-node-bg: #4a044e; + --gp-sphinx-badge-node-fg: #f0abfc; + --gp-sphinx-badge-node-border: #e879f9; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -464,6 +476,7 @@ body[data-theme="dark"] { .gp-sphinx-badge--type-reader { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-reader-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-reader-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-reader-border); } .gp-sphinx-badge--type-parser { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-parser-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-parser-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-parser-border); } .gp-sphinx-badge--type-writer { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-writer-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-writer-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-writer-border); } +.gp-sphinx-badge--type-node { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-node-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-node-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-node-border); } /* ══════════════════════════════════════════════════════════ * Colour classes — docutils component modifiers (outlined) diff --git a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py index 6001f24d..5497f8b1 100644 --- a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py +++ b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py @@ -83,6 +83,18 @@ def parse(self, inputstring, document): pass + class demo_chip(nodes.General, nodes.Inline, nodes.Element): + \"\"\"Inline chip node for demos.\"\"\" + + + def visit_demo_chip(translator, node): + pass + + + def depart_demo_chip(translator, node): + pass + + class DemoTranslator(nodes.SparseNodeVisitor): \"\"\"Translate demo documents to plain text.\"\"\" @@ -107,6 +119,7 @@ def translate(self): def setup(app): app.add_transform(DemoTransform) app.add_source_parser(DemoParser) + app.add_node(demo_chip, html=(visit_demo_chip, depart_demo_chip)) """ ) @@ -143,6 +156,8 @@ def setup(app): .. autoparser:: demo_docutils_objects.DemoParser .. autowriter:: demo_docutils_objects.DemoWriter + + .. autonode:: demo_docutils_objects.demo_chip """ ) @@ -249,3 +264,17 @@ def test_autodoc_docutils_writer_entries( assert "Translator class" in html assert "demo_docutils_objects.DemoTranslator" in html assert "demo-plain" in html + + +@pytest.mark.integration +def test_autodoc_docutils_node_entries( + autodoc_docutils_html_result: SharedSphinxResult, +) -> None: + """autonode entries render with profile, badge, and facts.""" + html = read_output(autodoc_docutils_html_result, "index.html") + + assert "gp-sphinx-api-profile--docutils-node" in html + assert ">node<" in html + assert "Base classes" in html + assert "Categories" in html + assert "Visit/depart handlers" in html diff --git a/tests/ext/autodoc_docutils/test_components.py b/tests/ext/autodoc_docutils/test_components.py index 5ab07bd7..0bb6d8c7 100644 --- a/tests/ext/autodoc_docutils/test_components.py +++ b/tests/ext/autodoc_docutils/test_components.py @@ -23,6 +23,14 @@ inject_component_badges, normalize_component_nodes, ) +from sphinx_autodoc_docutils._nodes_doc import ( + NodeInfo, + _node_fact_rows, + _nodes_from_calls, + discover_node, + discover_nodes, + node_categories, +) from sphinx_autodoc_docutils._parsers_doc import ( ParserInfo, _parser_fact_rows, @@ -430,6 +438,140 @@ def test_parser_fact_rows_include_source_parser_registration() -> None: assert by_label["Registered via"] == "app.add_source_parser()" +# --------------------------------------------------------------------------- +# Nodes +# --------------------------------------------------------------------------- + + +class _demo_inline(nodes.General, nodes.Inline, nodes.Element): # noqa: N801 — docutils node classes are lowercase + """Demo inline node for metadata tests.""" + + +def _visit_demo_inline(translator: object, node: object) -> None: + """Demo visit handler.""" + + +def _depart_demo_inline(translator: object, node: object) -> None: + """Demo depart handler.""" + + +class NodesFromCallsCase(t.NamedTuple): + """Test case for _nodes_from_calls().""" + + test_id: str + calls: list[tuple[str, tuple[object, ...], dict[str, object]]] + expected: list[tuple[str, tuple[str, ...]]] + + +_NODES_FROM_CALLS_CASES: list[NodesFromCallsCase] = [ + NodesFromCallsCase( + test_id="single_builder", + calls=[ + ( + "add_node", + (_demo_inline,), + {"html": (_visit_demo_inline, _depart_demo_inline)}, + ), + ], + expected=[("_demo_inline", ("html",))], + ), + NodesFromCallsCase( + test_id="override_kwarg_skipped", + calls=[ + ( + "add_node", + (_demo_inline,), + {"override": True, "html": (_visit_demo_inline, None)}, + ), + ], + expected=[("_demo_inline", ("html",))], + ), + NodesFromCallsCase( + test_id="multiple_builders", + calls=[ + ( + "add_node", + (_demo_inline,), + { + "html": (_visit_demo_inline, None), + "latex": (_visit_demo_inline, None), + }, + ), + ], + expected=[("_demo_inline", ("html", "latex"))], + ), + NodesFromCallsCase( + test_id="last_registration_wins", + calls=[ + ("add_node", (_demo_inline,), {"html": (_visit_demo_inline, None)}), + ("add_node", (_demo_inline,), {"text": (_visit_demo_inline, None)}), + ], + expected=[("_demo_inline", ("text",))], + ), + NodesFromCallsCase( + test_id="ignores_non_node_classes", + calls=[("add_node", (object,), {})], + expected=[], + ), +] + + +@pytest.mark.parametrize( + "case", + _NODES_FROM_CALLS_CASES, + ids=lambda c: c.test_id, +) +def test_nodes_from_calls(case: NodesFromCallsCase) -> None: + """_nodes_from_calls extracts node registrations with handlers.""" + infos = _nodes_from_calls(case.calls) + assert [(info.cls.__name__, info.handlers) for info in infos] == case.expected + + +def test_discover_nodes_merges_registration_into_scan() -> None: + """discover_nodes surfaces registered nodes with their handlers.""" + infos = discover_nodes("sphinx_ux_badges") + assert [(info.cls.__name__, info.handlers) for info in infos] == [ + ("BadgeNode", ("html",)), + ] + + +def test_discover_nodes_empty_for_module_without_nodes() -> None: + """discover_nodes returns [] for modules without node classes.""" + assert discover_nodes("sphinx_fonts") == [] + + +def test_discover_node_single_path() -> None: + """discover_node imports one node class and picks up its handlers.""" + info = discover_node("sphinx_ux_badges.BadgeNode") + assert info.cls.__name__ == "BadgeNode" + + +def test_node_categories_for_inline_node() -> None: + """node_categories reports docutils element category mixins. + + ``General`` subclasses ``Body`` in docutils, so a General node is + also a Body node. + """ + assert node_categories(_demo_inline) == ["Body", "General", "Inline"] + + +def test_node_fact_rows_surface_bases_and_handlers() -> None: + """Node fact rows include base classes, categories, and handlers.""" + rows = _node_fact_rows(NodeInfo(cls=_demo_inline, handlers=("html",))) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Python path"].endswith("_demo_inline") + assert "General" in by_label["Base classes"] + assert by_label["Categories"] == "Body, General, Inline" + assert by_label["Visit/depart handlers"] == "html" + + +def test_node_fact_rows_dash_without_handlers() -> None: + """Translator-handled nodes (no add_node call) show a handler dash.""" + rows = _node_fact_rows(NodeInfo(cls=_demo_inline)) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Visit/depart handlers"] == "—" + + # --------------------------------------------------------------------------- # Writers # --------------------------------------------------------------------------- From 36834cae8852bb177687800f9b8061b1d16272b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:20:53 -0500 Subject: [PATCH 08/33] gp-sphinx(autodoc-docutils[autotranslator]): Document translator classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Issue #52 — the interesting fact about a translator subclass is which visit/depart handlers it overrides (django-docutils' DjangoDocutilsHTMLTranslator overrides six), and naive dir()-based introspection drowns that in hundreds of inherited handlers. what: - Add autotranslator/autotranslators directives combining a NodeVisitor subclass scan with recorded app.set_translator() calls (builder name + override flag) - Overrides fact uses vars(cls) so only the class's own visit_/ depart_ methods surface; SparseNodeVisitor-style generated handlers on intermediate bases stay out of subclass listings - Badge group: filled "translator" kind badge plus the existing STATE_OVERRIDE outline badge when registered with override=True - SAB.TYPE_TRANSLATOR blue palette + ("docutils", "translator") layout profile; demo setup() registers the demo text translator with override; examples page sections --- docs/_ext/docutils_demo_components.py | 5 + .../sphinx-autodoc-docutils/examples.md | 17 ++ .../src/sphinx_autodoc_docutils/__init__.py | 14 ++ .../src/sphinx_autodoc_docutils/_badges.py | 44 ++++ .../_translators_doc.py | 233 ++++++++++++++++++ .../sphinx_ux_autodoc_layout/_transforms.py | 1 + .../src/sphinx_ux_badges/_css.py | 1 + .../_static/css/sab_palettes.css | 13 + .../test_autodoc_docutils_integration.py | 18 ++ tests/ext/autodoc_docutils/test_components.py | 84 +++++++ 10 files changed, 430 insertions(+) create mode 100644 packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py diff --git a/docs/_ext/docutils_demo_components.py b/docs/_ext/docutils_demo_components.py index 1486ddf4..5ade3a63 100644 --- a/docs/_ext/docutils_demo_components.py +++ b/docs/_ext/docutils_demo_components.py @@ -153,6 +153,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: ... self.calls.append(("add_source_parser", cls)) ... def add_node(self, cls: object, **kwargs: object) -> None: ... self.calls.append(("add_node", cls)) + ... def set_translator(self, name: str, cls: object, **kwargs: object) -> None: + ... self.calls.append(("set_translator", cls)) >>> fake = FakeApp() >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_transform", DemoReorderTransform) in fake.calls @@ -161,12 +163,15 @@ def setup(app: Sphinx) -> ExtensionMetadata: True >>> ("add_node", demo_marker) in fake.calls True + >>> ("set_translator", DemoTextTranslator) in fake.calls + True >>> metadata["parallel_read_safe"] True """ app.add_transform(DemoReorderTransform) app.add_source_parser(DemoLineParser) app.add_node(demo_marker, html=(visit_demo_marker, depart_demo_marker)) + app.set_translator("demo-plain", DemoTextTranslator, override=True) return { "version": "0.0.0", "parallel_read_safe": True, diff --git a/docs/packages/sphinx-autodoc-docutils/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md index e77df271..650da486 100644 --- a/docs/packages/sphinx-autodoc-docutils/examples.md +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -127,6 +127,23 @@ registered for via `app.add_node()`: :no-index: ``` +### Document one demo translator + +Translators surface their base class, the visit/depart methods the +class itself defines, and the builder the module's `setup()` registers +them for via `app.set_translator()` — including an `override` badge: + +```{eval-rst} +.. autotranslator:: docutils_demo_components.DemoTextTranslator +``` + +### Bulk translators demo + +```{eval-rst} +.. autotranslators:: docutils_demo_components + :no-index: +``` + ### Cross-referencing components Component entries register targets in the `docutils` domain, so prose diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 90f0b0d4..4f4b98d7 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -41,6 +41,13 @@ discover_transform, discover_transforms, ) +from sphinx_autodoc_docutils._translators_doc import ( + AutoTranslator, + AutoTranslators, + TranslatorInfo, + discover_translator, + discover_translators, +) from sphinx_autodoc_docutils._writers_doc import ( AutoWriter, AutoWriters, @@ -65,6 +72,8 @@ "AutoRoles", "AutoTransform", "AutoTransforms", + "AutoTranslator", + "AutoTranslators", "AutoWriter", "AutoWriters", "DocutilsComponentIndex", @@ -73,6 +82,7 @@ "ParserInfo", "SetupRecorder", "TransformInfo", + "TranslatorInfo", "discover_node", "discover_nodes", "discover_parser", @@ -81,6 +91,8 @@ "discover_readers", "discover_transform", "discover_transforms", + "discover_translator", + "discover_translators", "discover_writer", "discover_writers", "replay_setup", @@ -145,6 +157,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_directive("autowriters", AutoWriters) app.add_directive("autonode", AutoNode) app.add_directive("autonodes", AutoNodes) + app.add_directive("autotranslator", AutoTranslator) + app.add_directive("autotranslators", AutoTranslators) _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py index f33ade24..217d823b 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py @@ -17,6 +17,7 @@ "parser": SAB.TYPE_PARSER, "writer": SAB.TYPE_WRITER, "node": SAB.TYPE_NODE, + "translator": SAB.TYPE_TRANSLATOR, } @@ -46,6 +47,49 @@ def build_kind_badge_group(kind: str) -> nodes.inline: ) +def build_translator_badge_group(*, override: bool = False) -> nodes.inline: + """Return header badges for one documented docutils translator. + + Parameters + ---------- + override : bool + Whether the translator was registered with + ``set_translator(..., override=True)``; rendered as an outlined + secondary badge. + + Returns + ------- + nodes.inline + Badge group for the entry header. + + Examples + -------- + >>> "translator" in build_translator_badge_group().astext() + True + >>> "override" in build_translator_badge_group(override=True).astext() + True + >>> "override" in build_translator_badge_group().astext() + False + """ + specs = [ + BadgeSpec( + "translator", + tooltip="Docutils translator", + classes=(SAB.TYPE_TRANSLATOR,), + ), + ] + if override: + specs.append( + BadgeSpec( + "override", + tooltip="Registered with set_translator(override=True)", + classes=(SAB.STATE_OVERRIDE,), + fill="outline", + ), + ) + return build_badge_group_from_specs(specs, classes=[_GROUP_CLASS]) + + def build_transform_badge_group(priority: int | None = None) -> nodes.inline: """Return header badges for one documented docutils transform. diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py new file mode 100644 index 00000000..379191e0 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py @@ -0,0 +1,233 @@ +"""Rendering directives for docutils translator documentation.""" + +from __future__ import annotations + +import inspect +import typing as t +from dataclasses import dataclass + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_docutils._badges import build_translator_badge_group +from sphinx_autodoc_docutils._components import ( + component_classes, + import_component, + render_component_nodes, +) +from sphinx_autodoc_docutils._directives import ( + _literal_paragraph, + _summary, + replay_setup, +) +from sphinx_autodoc_docutils.domain import TRANSLATOR +from sphinx_ux_autodoc_layout import ApiFactRow + +if t.TYPE_CHECKING: + from sphinx.util.typing import OptionSpec + + +@dataclass(frozen=True) +class TranslatorInfo: + """Recorded metadata for one documented translator class. + + Examples + -------- + >>> from docutils.writers.html5_polyglot import HTMLTranslator + >>> info = TranslatorInfo(cls=HTMLTranslator, builder_name="html") + >>> info.qualified_name + 'docutils.writers.html5_polyglot.HTMLTranslator' + >>> info.builder_name + 'html' + """ + + cls: type[nodes.NodeVisitor] + builder_name: str = "" + override: bool = False + + @property + def qualified_name(self) -> str: + """Return the fully-qualified dotted path for the class. + + Examples + -------- + >>> TranslatorInfo(cls=nodes.SparseNodeVisitor).qualified_name + 'docutils.nodes.SparseNodeVisitor' + """ + return f"{self.cls.__module__}.{self.cls.__name__}" + + +def translator_overrides(cls: type[nodes.NodeVisitor]) -> list[str]: + """Return the visit/depart methods defined on the class itself. + + Uses ``vars()`` rather than ``dir()`` so only the class's own + overrides surface, not the hundreds of handlers inherited from its + base translator. + + Examples + -------- + >>> from docutils.writers.html5_polyglot import HTMLTranslator + >>> "depart_acronym" in translator_overrides(HTMLTranslator) + True + + ``SparseNodeVisitor`` generates every handler directly on the + class, so only the abstract ``NodeVisitor`` base is truly empty: + + >>> translator_overrides(nodes.NodeVisitor) + [] + """ + return sorted(name for name in vars(cls) if name.startswith(("visit_", "depart_"))) + + +def _translators_from_calls( + calls: list[tuple[str, tuple[object, ...], dict[str, object]]], +) -> list[TranslatorInfo]: + """Extract translator metadata from recorded ``set_translator`` calls. + + Examples + -------- + >>> infos = _translators_from_calls( + ... [ + ... ("set_translator", ("html", nodes.SparseNodeVisitor), {"override": True}), + ... ("add_directive", ("noise", object), {}), + ... ], + ... ) + >>> [(info.builder_name, info.override) for info in infos] + [('html', True)] + """ + infos: list[TranslatorInfo] = [] + for call_name, args, kwargs in calls: + if call_name != "set_translator" or len(args) < 2: + continue + builder_name, cls = args[0], args[1] + if not ( + isinstance(builder_name, str) + and inspect.isclass(cls) + and issubclass(cls, nodes.NodeVisitor) + ): + continue + override = bool(kwargs.get("override", args[2] if len(args) > 2 else False)) + infos.append( + TranslatorInfo(cls=cls, builder_name=builder_name, override=override), + ) + return infos + + +def discover_translators(module_name: str) -> list[TranslatorInfo]: + """Return translator classes a module defines or registers. + + Combines a module subclass scan for + :class:`~docutils.nodes.NodeVisitor` subclasses with the module's + recorded ``app.set_translator()`` calls, so scanned classes carry + their builder registration and override flag. + + Examples + -------- + >>> infos = discover_translators("docutils.writers.html5_polyglot") + >>> [(info.cls.__name__, info.builder_name) for info in infos] + [('HTMLTranslator', '')] + + >>> discover_translators("sphinx_fonts") + [] + """ + recorder = replay_setup(module_name) + registered = _translators_from_calls(recorder.calls) if recorder is not None else [] + by_cls = {info.cls: info for info in registered} + infos = [ + by_cls.get(cls, TranslatorInfo(cls=cls)) + for cls in component_classes(module_name, nodes.NodeVisitor) + ] + scanned = {info.cls for info in infos} + infos.extend(info for info in registered if info.cls not in scanned) + return infos + + +def discover_translator(path: str) -> TranslatorInfo: + """Return one translator from a fully-qualified dotted path. + + Examples + -------- + >>> info = discover_translator("docutils.writers.html5_polyglot.HTMLTranslator") + >>> info.cls.__name__ + 'HTMLTranslator' + """ + cls = t.cast("type[nodes.NodeVisitor]", import_component(path)) + for info in discover_translators(cls.__module__): + if info.cls is cls: + return info + return TranslatorInfo(cls=cls) + + +def _translator_fact_rows(info: TranslatorInfo) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented translator. + + Examples + -------- + >>> from docutils.writers.html5_polyglot import HTMLTranslator + >>> rows = _translator_fact_rows(TranslatorInfo(cls=HTMLTranslator)) + >>> [row.label for row in rows] + ['Python path', 'Base class', 'Overrides'] + """ + overrides = ", ".join(translator_overrides(info.cls)) or "—" + rows = [ + ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow( + "Base class", + _literal_paragraph(info.cls.__bases__[0].__name__), + ), + ApiFactRow("Overrides", _literal_paragraph(overrides)), + ] + if info.builder_name: + rows.append( + ApiFactRow( + "Registered for builder", + _literal_paragraph(info.builder_name), + ), + ) + return rows + + +def _render_translator( + directive: SphinxDirective, + info: TranslatorInfo, + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one translator entry through the shared component pipeline.""" + return render_component_nodes( + directive, + objtype=TRANSLATOR, + path=info.qualified_name, + summary=_summary(info.cls), + fact_rows=_translator_fact_rows(info), + badge_group=build_translator_badge_group(override=info.override), + no_index=no_index, + ) + + +class AutoTranslator(SphinxDirective): + """Render documentation for a single translator class.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + info = discover_translator(self.arguments[0]) + return _render_translator(self, info, no_index="no-index" in self.options) + + +class AutoTranslators(SphinxDirective): + """Render documentation for every translator a module defines or registers.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for info in discover_translators(self.arguments[0]): + results.extend(_render_translator(self, info, no_index=no_index)) + return results diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index fda9b552..b46ca59e 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -83,6 +83,7 @@ "parser", "writer", "node", + "translator", ) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index 8cb5063f..87aed315 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -168,6 +168,7 @@ class SAB: TYPE_PARSER = "gp-sphinx-badge--type-parser" TYPE_WRITER = "gp-sphinx-badge--type-writer" TYPE_NODE = "gp-sphinx-badge--type-node" + TYPE_TRANSLATOR = "gp-sphinx-badge--type-translator" # ── docutils component modifiers (outlined) ────────── MOD_PRIORITY = "gp-sphinx-badge--mod-priority" diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index 4a9166ce..d61580c6 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -136,6 +136,10 @@ --gp-sphinx-badge-node-fg: #a21caf; --gp-sphinx-badge-node-border: #d946ef; + --gp-sphinx-badge-translator-bg: #eff6ff; + --gp-sphinx-badge-translator-fg: #1d4ed8; + --gp-sphinx-badge-translator-border: #3b82f6; + /* ── docutils component modifiers (outlined) ────────── */ --gp-sphinx-badge-mod-priority-fg: #6b7280; --gp-sphinx-badge-mod-priority-border: #d1d5db; @@ -268,6 +272,10 @@ --gp-sphinx-badge-node-fg: #f0abfc; --gp-sphinx-badge-node-border: #e879f9; + --gp-sphinx-badge-translator-bg: #172554; + --gp-sphinx-badge-translator-fg: #93c5fd; + --gp-sphinx-badge-translator-border: #60a5fa; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -399,6 +407,10 @@ body[data-theme="dark"] { --gp-sphinx-badge-node-fg: #f0abfc; --gp-sphinx-badge-node-border: #e879f9; + --gp-sphinx-badge-translator-bg: #172554; + --gp-sphinx-badge-translator-fg: #93c5fd; + --gp-sphinx-badge-translator-border: #60a5fa; + --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; @@ -477,6 +489,7 @@ body[data-theme="dark"] { .gp-sphinx-badge--type-parser { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-parser-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-parser-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-parser-border); } .gp-sphinx-badge--type-writer { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-writer-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-writer-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-writer-border); } .gp-sphinx-badge--type-node { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-node-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-node-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-node-border); } +.gp-sphinx-badge--type-translator { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-translator-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-translator-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-translator-border); } /* ══════════════════════════════════════════════════════════ * Colour classes — docutils component modifiers (outlined) diff --git a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py index 5497f8b1..0a650a2b 100644 --- a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py +++ b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py @@ -120,6 +120,7 @@ def setup(app): app.add_transform(DemoTransform) app.add_source_parser(DemoParser) app.add_node(demo_chip, html=(visit_demo_chip, depart_demo_chip)) + app.set_translator("demo-plain", DemoTranslator, override=True) """ ) @@ -158,6 +159,8 @@ def setup(app): .. autowriter:: demo_docutils_objects.DemoWriter .. autonode:: demo_docutils_objects.demo_chip + + .. autotranslator:: demo_docutils_objects.DemoTranslator """ ) @@ -278,3 +281,18 @@ def test_autodoc_docutils_node_entries( assert "Base classes" in html assert "Categories" in html assert "Visit/depart handlers" in html + + +@pytest.mark.integration +def test_autodoc_docutils_translator_entries( + autodoc_docutils_html_result: SharedSphinxResult, +) -> None: + """autotranslator entries render with profile, badges, and facts.""" + html = read_output(autodoc_docutils_html_result, "index.html") + + assert "gp-sphinx-api-profile--docutils-translator" in html + assert ">translator<" in html + assert ">override<" in html + assert "Overrides" in html + assert "visit_paragraph" in html + assert "Registered for builder" in html diff --git a/tests/ext/autodoc_docutils/test_components.py b/tests/ext/autodoc_docutils/test_components.py index 0bb6d8c7..51814549 100644 --- a/tests/ext/autodoc_docutils/test_components.py +++ b/tests/ext/autodoc_docutils/test_components.py @@ -50,6 +50,14 @@ discover_transform, discover_transforms, ) +from sphinx_autodoc_docutils._translators_doc import ( + TranslatorInfo, + _translator_fact_rows, + _translators_from_calls, + discover_translator, + discover_translators, + translator_overrides, +) from sphinx_autodoc_docutils._writers_doc import ( _writer_fact_rows, discover_writer, @@ -572,6 +580,82 @@ def test_node_fact_rows_dash_without_handlers() -> None: assert by_label["Visit/depart handlers"] == "—" +# --------------------------------------------------------------------------- +# Translators +# --------------------------------------------------------------------------- + + +class _DemoVisitor(nodes.SparseNodeVisitor): + """Demo translator overriding two paragraph handlers.""" + + def visit_paragraph(self, node: nodes.paragraph) -> None: + """Demo visit handler.""" + + def depart_paragraph(self, node: nodes.paragraph) -> None: + """Demo depart handler.""" + + +def test_translator_overrides_lists_own_methods_only() -> None: + """translator_overrides reports only methods defined on the class.""" + assert translator_overrides(_DemoVisitor) == [ + "depart_paragraph", + "visit_paragraph", + ] + + +def test_translators_from_calls_extracts_builder_and_override() -> None: + """_translators_from_calls captures builder name and override flag.""" + infos = _translators_from_calls( + [ + ("set_translator", ("html", _DemoVisitor), {"override": True}), + ("set_translator", ("text", _DemoVisitor, False), {}), + ("set_translator", (123, _DemoVisitor), {}), + ("add_directive", ("noise", object), {}), + ], + ) + assert [(info.builder_name, info.override) for info in infos] == [ + ("html", True), + ("text", False), + ] + + +def test_discover_translators_scans_module() -> None: + """discover_translators finds NodeVisitor subclasses in a module.""" + infos = discover_translators("docutils.writers.html5_polyglot") + assert [(info.cls.__name__, info.builder_name) for info in infos] == [ + ("HTMLTranslator", ""), + ] + + +def test_discover_translators_empty_for_module_without_translators() -> None: + """discover_translators returns [] for modules without translators.""" + assert discover_translators("sphinx_fonts") == [] + + +def test_discover_translator_single_path() -> None: + """discover_translator imports one translator from a dotted path.""" + info = discover_translator("docutils.writers.html5_polyglot.HTMLTranslator") + assert info.cls.__name__ == "HTMLTranslator" + + +def test_translator_fact_rows_surface_base_and_overrides() -> None: + """Translator fact rows include base class and own overrides.""" + rows = _translator_fact_rows(TranslatorInfo(cls=_DemoVisitor)) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Base class"] == "SparseNodeVisitor" + assert by_label["Overrides"] == "depart_paragraph, visit_paragraph" + assert "Registered for builder" not in by_label + + +def test_translator_fact_rows_include_builder_registration() -> None: + """A set_translator registration surfaces the builder name.""" + rows = _translator_fact_rows( + TranslatorInfo(cls=_DemoVisitor, builder_name="html", override=True), + ) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Registered for builder"] == "html" + + # --------------------------------------------------------------------------- # Writers # --------------------------------------------------------------------------- From 8ae09314ec6dc8f4f4bc0273ea2f64f76359a589 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:29:39 -0500 Subject: [PATCH 09/33] gp-sphinx(autodoc-sphinx[autobuilder]): Document Sphinx builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Issue #52 — builders are pure Sphinx extension objects, so their autodoc lives beside config values in this package rather than in the docutils sibling. Their name/format/image-type surface was previously only documentable as a plain py:class. what: - Add autobuilder/autobuilders directives discovering builders from recorded app.add_builder() calls with a Builder subclass-scan fallback; facts surface builder name, output format, supported image types, default translator (getattr — not on the base class), parallel-safety, and epilog - Add the package's _components.py rendering pipeline targeting the sphinxext domain, with a cached replay_setup mirroring the docutils side; kept self-contained so the package installs standalone - Badge group: filled amber "builder" kind badge plus an outlined output-format badge (SAB.TYPE_BUILDER, SAB.MOD_FORMAT) - ("sphinxext", "builder") layout profile; demo archive builder; examples page sections with {sphinxext:builder} xref demo; unit + shared-scenario + two-page xref-resolution integration coverage --- docs/_ext/sphinx_demo_builder.py | 78 +++++ .../sphinx-autodoc-sphinx/examples.md | 24 ++ .../src/sphinx_autodoc_sphinx/__init__.py | 14 + .../src/sphinx_autodoc_sphinx/_badges.py | 43 +++ .../sphinx_autodoc_sphinx/_builders_doc.py | 225 +++++++++++++ .../src/sphinx_autodoc_sphinx/_components.py | 297 ++++++++++++++++++ .../sphinx_ux_autodoc_layout/_transforms.py | 10 + .../src/sphinx_ux_badges/_css.py | 6 + .../_static/css/sab_palettes.css | 33 ++ .../test_autodoc_sphinx_integration.py | 43 +++ tests/ext/autodoc_sphinx/test_components.py | 288 +++++++++++++++++ .../test_domain_xref_integration.py | 174 ++++++++++ 12 files changed, 1235 insertions(+) create mode 100644 docs/_ext/sphinx_demo_builder.py create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py create mode 100644 tests/ext/autodoc_sphinx/test_components.py create mode 100644 tests/ext/autodoc_sphinx/test_domain_xref_integration.py diff --git a/docs/_ext/sphinx_demo_builder.py b/docs/_ext/sphinx_demo_builder.py new file mode 100644 index 00000000..1cdda912 --- /dev/null +++ b/docs/_ext/sphinx_demo_builder.py @@ -0,0 +1,78 @@ +"""Synthetic Sphinx extension components for live autodoc demos. + +Grows one demo class per component type so the +``docs/packages/sphinx-autodoc-sphinx`` examples page can exercise the +``autobuilder`` and ``autodomain`` directives against realistic +metadata. + +Examples +-------- +>>> DemoArchiveBuilder.name +'demo-archive' +""" + +from __future__ import annotations + +import typing as t + +from sphinx.builders import Builder + +if t.TYPE_CHECKING: + from collections.abc import Iterator, Set + + from docutils import nodes + from sphinx.application import Sphinx + from sphinx.util.typing import ExtensionMetadata + + +class DemoArchiveBuilder(Builder): + """Bundle every rendered page into one archive artifact. + + A deliberately small builder: it reports all documents as outdated, + writes nothing per page, and exists so the autodoc output has a + realistic name/format/image-type surface to display. + """ + + name = "demo-archive" + format = "archive" + epilog = "The demo archive is in %(outdir)s." + supported_image_types: list[str] = ["image/svg+xml", "image/png"] # noqa: RUF012 — matches upstream sphinx.builders.Builder shape + + def get_outdated_docs(self) -> Iterator[str]: + """Report every document as outdated.""" + yield from self.env.found_docs + + def get_target_uri(self, docname: str, typ: str | None = None) -> str: + """Return the in-archive URI for a document.""" + return f"{docname}.txt" + + def prepare_writing(self, docnames: Set[str]) -> None: + """No writer state is needed for the demo.""" + + def write_doc(self, docname: str, doctree: nodes.document) -> None: + """Skip per-document output; the demo archives nothing.""" + + +def setup(app: Sphinx) -> ExtensionMetadata: + """Register the demo extension components with Sphinx. + + Examples + -------- + >>> class FakeApp: + ... def __init__(self) -> None: + ... self.calls: list[tuple[str, object]] = [] + ... def add_builder(self, cls: object) -> None: + ... self.calls.append(("add_builder", cls)) + >>> fake = FakeApp() + >>> metadata = setup(fake) # type: ignore[arg-type] + >>> ("add_builder", DemoArchiveBuilder) in fake.calls + True + >>> metadata["parallel_read_safe"] + True + """ + app.add_builder(DemoArchiveBuilder) + return { + "version": "0.0.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/packages/sphinx-autodoc-sphinx/examples.md b/docs/packages/sphinx-autodoc-sphinx/examples.md index be1cb940..12ee0835 100644 --- a/docs/packages/sphinx-autodoc-sphinx/examples.md +++ b/docs/packages/sphinx-autodoc-sphinx/examples.md @@ -22,3 +22,27 @@ Renders all config values from a module at once: ```{eval-rst} .. autoconfigvalues:: sphinx_config_demo ``` + +### Document one demo builder + +Builders surface their CLI name, output format, supported image types, +and parallel-safety: + +```{eval-rst} +.. autobuilder:: sphinx_demo_builder.DemoArchiveBuilder +``` + +### Bulk builders demo + +Renders every builder a module registers via `setup()`: + +```{eval-rst} +.. autobuilders:: sphinx_demo_builder + :no-index: +``` + +### Cross-referencing components + +Component entries register targets in the `sphinxext` domain, so prose +can link to them: {sphinxext:builder}`DemoArchiveBuilder` resolves to +the entry above. diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index 5454c648..92b19882 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -6,6 +6,13 @@ import pathlib import typing as t +from sphinx_autodoc_sphinx._builders_doc import ( + AutoBuilder, + AutoBuilders, + BuilderInfo, + discover_builder, + discover_builders, +) from sphinx_autodoc_sphinx._directives import ( AutoconfigvalueDirective, AutoconfigvaluesDirective, @@ -16,10 +23,15 @@ ) __all__ = [ + "AutoBuilder", + "AutoBuilders", "AutoconfigvalueDirective", "AutoconfigvaluesDirective", + "BuilderInfo", "SphinxExtComponentIndex", "SphinxExtDomain", + "discover_builder", + "discover_builders", "setup", ] @@ -67,6 +79,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_domain(SphinxExtDomain) app.add_directive("autoconfigvalue", AutoconfigvalueDirective) app.add_directive("autoconfigvalues", AutoconfigvaluesDirective) + app.add_directive("autobuilder", AutoBuilder) + app.add_directive("autobuilders", AutoBuilders) _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py index e33f181b..6cd7c179 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py @@ -44,3 +44,46 @@ def build_config_badge_group(value: SphinxConfigValue) -> nodes.inline: ], classes=[_GROUP_CLASS], ) + + +def build_builder_badge_group(output_format: str = "") -> nodes.inline: + """Return header badges for one documented Sphinx builder. + + Parameters + ---------- + output_format : str + The builder's ``format`` attribute; rendered as an outlined + secondary badge when non-empty. + + Returns + ------- + nodes.inline + Badge group for the entry header. + + Examples + -------- + >>> group = build_builder_badge_group("html") + >>> "builder" in group.astext() + True + >>> "html" in group.astext() + True + >>> build_builder_badge_group("").astext() + 'builder' + """ + specs = [ + BadgeSpec( + "builder", + tooltip="Sphinx builder", + classes=(SAB.TYPE_BUILDER,), + ), + ] + if output_format: + specs.append( + BadgeSpec( + output_format, + tooltip=f"Output format: {output_format}", + classes=(SAB.MOD_FORMAT,), + fill="outline", + ), + ) + return build_badge_group_from_specs(specs, classes=[_GROUP_CLASS]) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py new file mode 100644 index 00000000..6178e776 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py @@ -0,0 +1,225 @@ +"""Rendering directives for Sphinx builder documentation.""" + +from __future__ import annotations + +import inspect +import typing as t +from dataclasses import dataclass + +from docutils.parsers.rst import directives +from sphinx.builders import Builder +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_sphinx._badges import build_builder_badge_group +from sphinx_autodoc_sphinx._components import ( + component_classes, + component_summary, + import_component, + render_component_nodes, + replay_setup, +) +from sphinx_autodoc_sphinx._directives import _literal_paragraph +from sphinx_autodoc_sphinx.domain import BUILDER +from sphinx_ux_autodoc_layout import ApiFactRow + +if t.TYPE_CHECKING: + from docutils import nodes + from sphinx.util.typing import OptionSpec + + +@dataclass(frozen=True) +class BuilderInfo: + """Recorded metadata for one documented builder class. + + Examples + -------- + >>> from sphinx.builders.dummy import DummyBuilder + >>> info = BuilderInfo(cls=DummyBuilder, registered=True) + >>> info.qualified_name + 'sphinx.builders.dummy.DummyBuilder' + >>> info.builder_name + 'dummy' + """ + + cls: type[Builder] + registered: bool = False + + @property + def qualified_name(self) -> str: + """Return the fully-qualified dotted path for the class. + + Examples + -------- + >>> from sphinx.builders.dummy import DummyBuilder + >>> BuilderInfo(cls=DummyBuilder).qualified_name + 'sphinx.builders.dummy.DummyBuilder' + """ + return f"{self.cls.__module__}.{self.cls.__name__}" + + @property + def builder_name(self) -> str: + """Return the builder's CLI name (``-b`` value). + + Examples + -------- + >>> from sphinx.builders.dummy import DummyBuilder + >>> BuilderInfo(cls=DummyBuilder).builder_name + 'dummy' + """ + return str(self.cls.name) + + +def _builders_from_calls( + calls: list[tuple[str, tuple[object, ...], dict[str, object]]], +) -> list[BuilderInfo]: + """Extract builder metadata from recorded ``add_builder`` calls. + + Examples + -------- + >>> from sphinx.builders.dummy import DummyBuilder + >>> infos = _builders_from_calls( + ... [ + ... ("add_builder", (DummyBuilder,), {}), + ... ("add_directive", ("noise", object), {}), + ... ], + ... ) + >>> [(info.cls.__name__, info.registered) for info in infos] + [('DummyBuilder', True)] + """ + infos: list[BuilderInfo] = [] + seen: set[type[Builder]] = set() + for call_name, args, _kwargs in calls: + if call_name != "add_builder" or len(args) < 1: + continue + cls = args[0] + if not (inspect.isclass(cls) and issubclass(cls, Builder)): + continue + if cls in seen: + continue + seen.add(cls) + infos.append(BuilderInfo(cls=cls, registered=True)) + return infos + + +def discover_builders(module_name: str) -> list[BuilderInfo]: + """Return builders a module registers, or defines as a fallback. + + Replays the module's ``setup()`` against a recorder so builders + surface with their ``app.add_builder()`` registration; falls back + to scanning the module for public + :class:`~sphinx.builders.Builder` subclasses. + + Examples + -------- + >>> infos = discover_builders("sphinx.builders.dummy") + >>> [(info.cls.__name__, info.registered) for info in infos] + [('DummyBuilder', True)] + + >>> discover_builders("sphinx_fonts") + [] + """ + recorder = replay_setup(module_name) + if recorder is not None: + infos = _builders_from_calls(recorder.calls) + if infos: + return infos + return [BuilderInfo(cls=cls) for cls in component_classes(module_name, Builder)] + + +def discover_builder(path: str) -> BuilderInfo: + """Return one builder from a fully-qualified dotted path. + + Examples + -------- + >>> info = discover_builder("sphinx.builders.dummy.DummyBuilder") + >>> info.builder_name + 'dummy' + """ + cls = t.cast("type[Builder]", import_component(path)) + for info in discover_builders(cls.__module__): + if info.cls is cls: + return info + return BuilderInfo(cls=cls) + + +def _builder_fact_rows(info: BuilderInfo) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented builder. + + Examples + -------- + >>> from sphinx.builders.dummy import DummyBuilder + >>> rows = _builder_fact_rows(BuilderInfo(cls=DummyBuilder)) + >>> [row.label for row in rows] # doctest: +NORMALIZE_WHITESPACE + ['Python path', 'Builder name', 'Output format', + 'Supported image types', 'Default translator', 'Parallel-safe', + 'Epilog'] + """ + cls = info.cls + image_types = ", ".join(cls.supported_image_types) or "—" + # default_translator_class only exists on translator-driven + # builders (StandaloneHTMLBuilder and friends), not the base. + translator = getattr(cls, "default_translator_class", None) + translator_path = ( + f"{translator.__module__}.{translator.__name__}" + if inspect.isclass(translator) + else "—" + ) + return [ + ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow("Builder name", _literal_paragraph(info.builder_name or "—")), + ApiFactRow("Output format", _literal_paragraph(str(cls.format) or "—")), + ApiFactRow("Supported image types", _literal_paragraph(image_types)), + ApiFactRow("Default translator", _literal_paragraph(translator_path)), + ApiFactRow("Parallel-safe", _literal_paragraph(str(cls.allow_parallel))), + ApiFactRow("Epilog", _literal_paragraph(str(cls.epilog) or "—")), + ] + + +def _render_builder( + directive: SphinxDirective, + info: BuilderInfo, + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one builder entry through the shared component pipeline.""" + return render_component_nodes( + directive, + objtype=BUILDER, + path=info.qualified_name, + summary=component_summary(info.cls), + fact_rows=_builder_fact_rows(info), + badge_group=build_builder_badge_group(str(info.cls.format)), + no_index=no_index, + ) + + +class AutoBuilder(SphinxDirective): + """Render documentation for a single builder class.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + info = discover_builder(self.arguments[0]) + return _render_builder(self, info, no_index="no-index" in self.options) + + +class AutoBuilders(SphinxDirective): + """Render documentation for every builder a package registers. + + Accepts either an extension package (whose ``setup()`` runs against + a recorder so each ``app.add_builder(cls)`` call surfaces) or a + builder-defining module (introspected for ``Builder`` subclasses). + """ + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for info in discover_builders(self.arguments[0]): + results.extend(_render_builder(self, info, no_index=no_index)) + return results diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py new file mode 100644 index 00000000..3dee9193 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py @@ -0,0 +1,297 @@ +"""Shared rendering pipeline for Sphinx extension component entries. + +Mirrors ``sphinx_autodoc_docutils._components`` for this package's +component types (builders, domains): markup generation targeting the +``sphinxext`` domain, badge injection, and fact-section insertion. Kept +self-contained so the package installs standalone without a dependency +on its docutils sibling. +""" + +from __future__ import annotations + +import functools +import importlib +import inspect +import logging +import typing as t + +from docutils import nodes +from sphinx import addnodes + +from sphinx_autodoc_sphinx._directives import RecorderApp +from sphinx_ux_autodoc_layout import ( + build_api_facts_section, + inject_signature_slots, + iter_desc_nodes, + parse_generated_markup, +) + +if t.TYPE_CHECKING: + from sphinx.util.docutils import SphinxDirective + + from sphinx_ux_autodoc_layout import ApiFactRow + +logger = logging.getLogger(__name__) + +_T = t.TypeVar("_T") + + +@functools.cache +def replay_setup(module_name: str) -> RecorderApp | None: + """Run a module's ``setup()`` against a recorder; None on failure. + + Cached for the same reason as the docutils-side replay: a docs + build invokes discovery once per directive call, and re-importing + plus re-replaying each package's ``setup()`` would repeat work. + Consumers iterate ``recorder.calls`` and never mutate it. + + Examples + -------- + >>> recorder = replay_setup("sphinx_fonts") + >>> any(name == "add_config_value" for name, _, _ in recorder.calls) + True + + >>> replay_setup("sphinx_autodoc_sphinx._components") is None + True + """ + try: + module = importlib.import_module(module_name) + except ImportError: + return None + setup_fn = getattr(module, "setup", None) + if not callable(setup_fn): + return None + recorder = RecorderApp() + try: + setup_fn(recorder) + except Exception: + logger.debug( + "setup replay failed for %s; falling back to module introspection", + module_name, + exc_info=True, + ) + return None + return recorder + + +def component_markup( + objtype: str, + path: str, + summary: str, + *, + no_index: bool = False, +) -> str: + """Return reStructuredText markup documenting one component class. + + Examples + -------- + >>> markup = component_markup( + ... "builder", + ... "pkg.builders.ZipBuilder", + ... "Bundle output into a zip archive.", + ... ) + >>> ".. sphinxext:builder:: pkg.builders.ZipBuilder" in markup + True + >>> ":no-index:" in component_markup("domain", "pkg.D", "", no_index=True) + True + """ + return "\n".join( + [ + f".. sphinxext:{objtype}:: {path}", + " :no-index:" if no_index else "", + "", + f" {summary or f'Autodocumented Sphinx {objtype}.'}", + ], + ) + + +def component_classes( + module_name: str, + base: type[_T], +) -> list[type[_T]]: + """Return public subclasses of *base* defined directly in a module. + + Examples + -------- + >>> from sphinx.builders import Builder + >>> classes = component_classes("sphinx.builders.dummy", Builder) + >>> [cls.__name__ for cls in classes] + ['DummyBuilder'] + + >>> component_classes("sphinx_fonts", Builder) + [] + """ + module = importlib.import_module(module_name) + results: list[type[_T]] = [] + for name, value in inspect.getmembers(module): + if ( + not name.startswith("_") + and inspect.isclass(value) + and getattr(value, "__module__", None) == module.__name__ + and issubclass(value, base) + and value is not base + ): + results.append(value) + return results + + +def component_summary(value: object) -> str: + """Return the first summary line for a Python object. + + ``inspect.getdoc`` falls back to inherited docstrings, so + undocumented subclasses summarize via their base class. + + Examples + -------- + >>> from sphinx.builders.dummy import DummyBuilder + >>> component_summary(DummyBuilder) + 'Builds target formats from the reST sources.' + """ + doc = inspect.getdoc(value) or "" + for line in doc.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +def import_component(path: str) -> type: + """Import one component class from a dotted ``module.ClassName`` path. + + Examples + -------- + >>> import_component("sphinx.builders.dummy.DummyBuilder").__name__ + 'DummyBuilder' + """ + module_name, _, attr_name = path.rpartition(".") + value = getattr(importlib.import_module(module_name), attr_name) + if not inspect.isclass(value): + msg = f"Expected a class at {path!r}, got {type(value).__name__}" + raise TypeError(msg) + return t.cast("type", value) + + +def inject_component_badges( + node_list: list[nodes.Node], + *, + objtype: str, + badge_group: nodes.inline, +) -> None: + """Attach shared badge-slot metadata to parsed ``sphinxext:*`` entries. + + Examples + -------- + >>> from sphinx_autodoc_sphinx._badges import build_builder_badge_group + >>> desc = addnodes.desc(domain="sphinxext", objtype="builder") + >>> sig = addnodes.desc_signature() + >>> desc += sig + >>> inject_component_badges( + ... [desc], + ... objtype="builder", + ... badge_group=build_builder_badge_group("zip"), + ... ) + >>> sig["sas_badges_injected"] + True + + Entries of another objtype are left untouched: + + >>> other = addnodes.desc(domain="sphinxext", objtype="domain") + >>> other_sig = addnodes.desc_signature() + >>> other += other_sig + >>> inject_component_badges( + ... [other], + ... objtype="builder", + ... badge_group=build_builder_badge_group("zip"), + ... ) + >>> other_sig.get("sas_badges_injected") is None + True + """ + for desc_node in iter_desc_nodes(node_list): + if ( + desc_node.get("domain") != "sphinxext" + or desc_node.get("objtype") != objtype + ): + continue + for sig_node in desc_node.children: + if not isinstance(sig_node, addnodes.desc_signature): + continue + inject_signature_slots( + sig_node, + marker_attr="sas_badges_injected", + badge_node=badge_group.deepcopy(), + extract_source_link=False, + ) + + +def normalize_component_nodes( + node_list: list[nodes.Node], + *, + objtype: str, + fact_rows: list[ApiFactRow], +) -> None: + """Attach the shared facts section to parsed component entries. + + The facts section lands directly after the leading summary + paragraphs inside ``desc_content``. + + Examples + -------- + >>> from sphinx_ux_autodoc_layout import ApiFactRow + >>> desc = addnodes.desc(domain="sphinxext", objtype="builder") + >>> desc += addnodes.desc_signature() + >>> content = addnodes.desc_content() + >>> content += nodes.paragraph("", "Summary.") + >>> desc += content + >>> body = nodes.paragraph() + >>> body += nodes.literal("demo", "demo") + >>> normalize_component_nodes( + ... [desc], + ... objtype="builder", + ... fact_rows=[ApiFactRow("Python path", body)], + ... ) + >>> content.children[1].get("name") + 'gp-sphinx-api-facts' + """ + for desc_node in iter_desc_nodes(node_list): + if ( + desc_node.get("domain") != "sphinxext" + or desc_node.get("objtype") != objtype + ): + continue + content = next( + ( + child + for child in desc_node.children + if isinstance(child, addnodes.desc_content) + ), + None, + ) + if content is None: + continue + insert_idx = 0 + while insert_idx < len(content.children) and isinstance( + content.children[insert_idx], + nodes.paragraph, + ): + insert_idx += 1 + content.insert(insert_idx, build_api_facts_section(fact_rows)) + + +def render_component_nodes( + directive: SphinxDirective, + *, + objtype: str, + path: str, + summary: str, + fact_rows: list[ApiFactRow], + badge_group: nodes.inline, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one component entry with badges and facts attached.""" + node_list = parse_generated_markup( + directive, + component_markup(objtype, path, summary, no_index=no_index), + ) + inject_component_badges(node_list, objtype=objtype, badge_group=badge_group) + normalize_component_nodes(node_list, objtype=objtype, fact_rows=fact_rows) + return node_list diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index b46ca59e..ef3d2d46 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -86,6 +86,8 @@ "translator", ) +_MANAGED_SPHINXEXT_OBJTYPES: tuple[str, ...] = ("builder",) + @dataclasses.dataclass(frozen=True, slots=True) class DescLayoutProfile: @@ -146,6 +148,14 @@ def class_name(self) -> str: ) for objtype in _MANAGED_DOCUTILS_OBJTYPES }, + **{ + ("sphinxext", objtype): DescLayoutProfile( + domain="sphinxext", + objtype=objtype, + slug=f"sphinxext-{objtype}", + ) + for objtype in _MANAGED_SPHINXEXT_OBJTYPES + }, } diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index 87aed315..68e2e16b 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -173,6 +173,12 @@ class SAB: # ── docutils component modifiers (outlined) ────────── MOD_PRIORITY = "gp-sphinx-badge--mod-priority" + # ── Sphinx extension components (filled) ───────────── + TYPE_BUILDER = "gp-sphinx-badge--type-builder" + + # ── Sphinx extension component modifiers (outlined) ── + MOD_FORMAT = "gp-sphinx-badge--mod-format" + # ── Package metadata (maturity + links) ─────────────── META_ALPHA = "gp-sphinx-badge--meta-alpha" META_BETA = "gp-sphinx-badge--meta-beta" diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index d61580c6..b74269a0 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -144,6 +144,15 @@ --gp-sphinx-badge-mod-priority-fg: #6b7280; --gp-sphinx-badge-mod-priority-border: #d1d5db; + /* ── Sphinx extension components (filled) ───────────── */ + --gp-sphinx-badge-builder-bg: #fffbeb; + --gp-sphinx-badge-builder-fg: #b45309; + --gp-sphinx-badge-builder-border: #f59e0b; + + /* ── Sphinx extension component modifiers (outlined) ── */ + --gp-sphinx-badge-mod-format-fg: #6b7280; + --gp-sphinx-badge-mod-format-border: #d1d5db; + /* ── Package metadata (maturity + links) ────────────── */ --gp-sphinx-badge-meta-alpha-bg: #ffedc6; --gp-sphinx-badge-meta-alpha-fg: #4e2009; @@ -279,6 +288,13 @@ --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; + --gp-sphinx-badge-builder-bg: #451a03; + --gp-sphinx-badge-builder-fg: #fcd34d; + --gp-sphinx-badge-builder-border: #fbbf24; + + --gp-sphinx-badge-mod-format-fg: #9ca3af; + --gp-sphinx-badge-mod-format-border: #4b5563; + --gp-sphinx-badge-meta-alpha-bg: #3f2700; --gp-sphinx-badge-meta-alpha-fg: #ffca16; --gp-sphinx-badge-meta-alpha-border: #8f6424; @@ -414,6 +430,13 @@ body[data-theme="dark"] { --gp-sphinx-badge-mod-priority-fg: #9ca3af; --gp-sphinx-badge-mod-priority-border: #4b5563; + --gp-sphinx-badge-builder-bg: #451a03; + --gp-sphinx-badge-builder-fg: #fcd34d; + --gp-sphinx-badge-builder-border: #fbbf24; + + --gp-sphinx-badge-mod-format-fg: #9ca3af; + --gp-sphinx-badge-mod-format-border: #4b5563; + --gp-sphinx-badge-meta-alpha-bg: #3f2700; --gp-sphinx-badge-meta-alpha-fg: #ffca16; --gp-sphinx-badge-meta-alpha-border: #8f6424; @@ -496,6 +519,16 @@ body[data-theme="dark"] { * ══════════════════════════════════════════════════════════ */ .gp-sphinx-badge--mod-priority { --gp-sphinx-badge-fg: var(--gp-sphinx-badge-mod-priority-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-mod-priority-border); } +/* ══════════════════════════════════════════════════════════ + * Colour classes — Sphinx extension components (filled) + * ══════════════════════════════════════════════════════════ */ +.gp-sphinx-badge--type-builder { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-builder-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-builder-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-builder-border); } + +/* ══════════════════════════════════════════════════════════ + * Colour classes — Sphinx extension component modifiers (outlined) + * ══════════════════════════════════════════════════════════ */ +.gp-sphinx-badge--mod-format { --gp-sphinx-badge-fg: var(--gp-sphinx-badge-mod-format-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-mod-format-border); } + /* ══════════════════════════════════════════════════════════ * Colour classes — package metadata (maturity + links) * ══════════════════════════════════════════════════════════ */ diff --git a/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py index 3939b640..a9c8487b 100644 --- a/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py +++ b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py @@ -19,6 +19,29 @@ """\ from __future__ import annotations + from sphinx.builders import Builder + + + class DemoZipBuilder(Builder): + \"\"\"Bundle rendered pages into a zip archive.\"\"\" + + name = "demo-zip" + format = "zip" + epilog = "The zip is in %(outdir)s." + supported_image_types = ["image/png"] + + def get_outdated_docs(self): + return [] + + def get_target_uri(self, docname, typ=None): + return docname + + def prepare_writing(self, docnames): + pass + + def write_doc(self, docname, doctree): + pass + def setup(app): app.add_config_value( @@ -40,6 +63,7 @@ def setup(app): types=(dict,), description="Color tokens for the demo extension.", ) + app.add_builder(DemoZipBuilder) """ ) @@ -63,6 +87,8 @@ def setup(app): =========== .. autoconfigvalues:: demo_sphinx_ext + + .. autobuilder:: demo_sphinx_ext.DemoZipBuilder """ ) @@ -113,3 +139,20 @@ def test_autodoc_sphinx_confvals_use_shared_layout( assert "Registered by" in html assert "highlight-python" in html assert "Rebuild:" not in html + + +@pytest.mark.integration +def test_autodoc_sphinx_builder_entries( + autodoc_sphinx_html_result: SharedSphinxResult, +) -> None: + """autobuilder entries render with profile, badges, and facts.""" + html = read_output(autodoc_sphinx_html_result, "index.html") + + assert "gp-sphinx-api-profile--sphinxext-builder" in html + assert ">builder<" in html + assert "gp-sphinx-badge--mod-format" in html + assert ">zip<" in html + assert "Builder name" in html + assert "demo-zip" in html + assert "image/png" in html + assert "Parallel-safe" in html diff --git a/tests/ext/autodoc_sphinx/test_components.py b/tests/ext/autodoc_sphinx/test_components.py new file mode 100644 index 00000000..7ffdc9c0 --- /dev/null +++ b/tests/ext/autodoc_sphinx/test_components.py @@ -0,0 +1,288 @@ +"""Unit tests for the Sphinx extension component autodoc pipeline. + +Covers per-type discovery, fact rows, and the shared +``normalize_component_nodes`` / ``inject_component_badges`` doctree +behavior for sphinx_autodoc_sphinx. Each component type contributes its +own section as it lands. +""" + +from __future__ import annotations + +import collections.abc as cabc +import typing as t + +import pytest +from docutils import nodes +from sphinx import addnodes +from sphinx.builders import Builder + +from sphinx_autodoc_sphinx._badges import build_builder_badge_group +from sphinx_autodoc_sphinx._builders_doc import ( + BuilderInfo, + _builder_fact_rows, + _builders_from_calls, + discover_builder, + discover_builders, +) +from sphinx_autodoc_sphinx._components import ( + component_classes, + component_markup, + import_component, + inject_component_badges, + normalize_component_nodes, + replay_setup, +) +from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout._nodes import api_component + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +class _DemoBuilder(Builder): + """Demo builder for metadata tests.""" + + name = "demo-test" + format = "test" + epilog = "Demo output is in %(outdir)s." + supported_image_types: list[str] = ["image/png"] # noqa: RUF012 — matches upstream sphinx.builders.Builder shape + + def get_outdated_docs(self) -> list[str]: + """Report nothing as outdated.""" + return [] + + def get_target_uri(self, docname: str, typ: str | None = None) -> str: + """Return the docname unchanged.""" + return docname + + def prepare_writing(self, docnames: cabc.Set[str]) -> None: + """No writer state is needed.""" + + def write_doc(self, docname: str, doctree: nodes.document) -> None: + """Skip per-document output.""" + + +def _make_component_desc( + objtype: str, + *, + name: str = "demo.DemoComponent", +) -> addnodes.desc: + """Build a minimal sphinxext-domain desc node as Auto* would produce.""" + desc = addnodes.desc(domain="sphinxext", objtype=objtype) + sig = addnodes.desc_signature(ids=[f"sphinxext-{objtype}-{name.lower()}"]) + sig += addnodes.desc_name("", name) + desc += sig + content = addnodes.desc_content() + content += nodes.paragraph("", "A demo component for testing.") + desc += content + return desc + + +def _api_facts_child(content: addnodes.desc_content) -> api_component | None: + """Return the gp-sphinx-api-facts component in desc_content, or None.""" + for child in content.children: + if ( + isinstance(child, api_component) + and child.get("name") == "gp-sphinx-api-facts" + ): + return child + return None + + +def _demo_fact_rows() -> list[ApiFactRow]: + """Return a small facts list for normalize tests.""" + paragraph = nodes.paragraph() + paragraph += nodes.literal("demo", "demo") + return [ApiFactRow("Python path", paragraph)] + + +# --------------------------------------------------------------------------- +# Shared pipeline +# --------------------------------------------------------------------------- + + +def test_component_markup_renders_domain_directive() -> None: + """component_markup emits a sphinxext-domain object description.""" + markup = component_markup("builder", "pkg.ZipBuilder", "Zip output.") + assert markup.splitlines()[0] == ".. sphinxext:builder:: pkg.ZipBuilder" + assert " Zip output." in markup + + +def test_normalize_component_inserts_api_facts_after_summary() -> None: + """normalize_component_nodes inserts gp-sphinx-api-facts after the summary.""" + desc = _make_component_desc("builder") + content = t.cast("addnodes.desc_content", desc.children[-1]) + + normalize_component_nodes( + [desc], + objtype="builder", + fact_rows=_demo_fact_rows(), + ) + + assert isinstance(content.children[0], nodes.paragraph) + assert _api_facts_child(content) is not None + + +def test_normalize_component_skips_other_objtypes() -> None: + """normalize_component_nodes leaves non-matching objtypes untouched.""" + builder_desc = _make_component_desc("builder") + domain_desc = _make_component_desc("domain") + + normalize_component_nodes( + [builder_desc, domain_desc], + objtype="builder", + fact_rows=_demo_fact_rows(), + ) + + domain_content = t.cast("addnodes.desc_content", domain_desc.children[-1]) + assert _api_facts_child(domain_content) is None + + +def test_inject_component_badges_marks_signature() -> None: + """inject_component_badges attaches the badge slot exactly once.""" + desc = _make_component_desc("builder") + sig = t.cast("addnodes.desc_signature", desc.children[0]) + + inject_component_badges( + [desc], + objtype="builder", + badge_group=build_builder_badge_group("zip"), + ) + + assert sig.get("sas_badges_injected") is True + + +def test_import_component_rejects_non_class() -> None: + """import_component raises TypeError for non-class attributes.""" + with pytest.raises(TypeError, match="Expected a class"): + import_component("sphinx.builders.dummy.__doc__") + + +def test_replay_setup_records_calls_for_extension_modules() -> None: + """replay_setup captures add_* calls from a real extension.""" + recorder = replay_setup("sphinx.builders.dummy") + assert recorder is not None + assert any(name == "add_builder" for name, _, _ in recorder.calls) + + +def test_replay_setup_none_for_module_without_setup() -> None: + """replay_setup returns None when the module has no setup().""" + assert replay_setup("sphinx_autodoc_sphinx._components") is None + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + + +class BuildersFromCallsCase(t.NamedTuple): + """Test case for _builders_from_calls().""" + + test_id: str + calls: list[tuple[str, tuple[object, ...], dict[str, object]]] + expected: list[str] + + +_BUILDERS_FROM_CALLS_CASES: list[BuildersFromCallsCase] = [ + BuildersFromCallsCase( + test_id="single_builder", + calls=[("add_builder", (_DemoBuilder,), {})], + expected=["_DemoBuilder"], + ), + BuildersFromCallsCase( + test_id="ignores_other_calls", + calls=[ + ("add_directive", ("noise", object), {}), + ("add_builder", (_DemoBuilder,), {}), + ], + expected=["_DemoBuilder"], + ), + BuildersFromCallsCase( + test_id="ignores_non_builder_classes", + calls=[("add_builder", (object,), {})], + expected=[], + ), + BuildersFromCallsCase( + test_id="dedupes_repeat_registrations", + calls=[ + ("add_builder", (_DemoBuilder,), {}), + ("add_builder", (_DemoBuilder,), {"override": True}), + ], + expected=["_DemoBuilder"], + ), +] + + +@pytest.mark.parametrize( + "case", + _BUILDERS_FROM_CALLS_CASES, + ids=lambda c: c.test_id, +) +def test_builders_from_calls(case: BuildersFromCallsCase) -> None: + """_builders_from_calls extracts builder registrations.""" + infos = _builders_from_calls(case.calls) + assert [info.cls.__name__ for info in infos] == case.expected + assert all(info.registered for info in infos) + + +def test_discover_builders_via_setup_registration() -> None: + """discover_builders surfaces builders from a module's setup().""" + infos = discover_builders("sphinx.builders.dummy") + assert [(info.cls.__name__, info.registered) for info in infos] == [ + ("DummyBuilder", True), + ] + + +def test_discover_builders_empty_for_module_without_builders() -> None: + """discover_builders returns [] for modules without builders.""" + assert discover_builders("sphinx_fonts") == [] + + +def test_discover_builder_single_path() -> None: + """discover_builder imports one builder from a dotted path.""" + info = discover_builder("sphinx.builders.dummy.DummyBuilder") + assert info.builder_name == "dummy" + + +def test_component_classes_scans_builder_modules() -> None: + """component_classes finds Builder subclasses defined in a module.""" + classes = component_classes("sphinx.builders.dummy", Builder) + assert [cls.__name__ for cls in classes] == ["DummyBuilder"] + + +def test_builder_fact_rows_surface_metadata() -> None: + """Builder fact rows include name, format, image types, and epilog.""" + rows = _builder_fact_rows(BuilderInfo(cls=_DemoBuilder, registered=True)) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Builder name"] == "demo-test" + assert by_label["Output format"] == "test" + assert by_label["Supported image types"] == "image/png" + assert by_label["Default translator"] == "—" + assert by_label["Parallel-safe"] == "False" + assert by_label["Epilog"] == "Demo output is in %(outdir)s." + + +def test_builder_fact_rows_dash_for_base_metadata() -> None: + """Builders inheriting blank base attributes degrade to dashes.""" + + class _BareBuilder(Builder): + """Builder leaving every base attribute untouched.""" + + def get_outdated_docs(self) -> list[str]: + return [] + + def get_target_uri(self, docname: str, typ: str | None = None) -> str: + return docname + + def prepare_writing(self, docnames: cabc.Set[str]) -> None: + pass + + def write_doc(self, docname: str, doctree: nodes.document) -> None: + pass + + rows = _builder_fact_rows(BuilderInfo(cls=_BareBuilder)) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Builder name"] == "—" + assert by_label["Output format"] == "—" + assert by_label["Supported image types"] == "—" diff --git a/tests/ext/autodoc_sphinx/test_domain_xref_integration.py b/tests/ext/autodoc_sphinx/test_domain_xref_integration.py new file mode 100644 index 00000000..cabd37dd --- /dev/null +++ b/tests/ext/autodoc_sphinx/test_domain_xref_integration.py @@ -0,0 +1,174 @@ +"""Integration tests for sphinxext-domain cross-reference resolution. + +Builds a two-page project: ``index.rst`` documents components (creating +domain targets), ``usage.rst`` cross-references them with +``:sphinxext:*:`` roles plus one deliberately dangling target so the +tests prove resolution actually runs. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tests._sphinx_scenarios import ( + SCENARIO_SRCDIR_TOKEN, + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + read_output, +) + +_MODULE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + + from sphinx.builders import Builder + + + class DemoXrefBuilder(Builder): + \"\"\"Builder used only for xref tests.\"\"\" + + name = "demo-xref" + format = "xref" + + def get_outdated_docs(self): + return [] + + def get_target_uri(self, docname, typ=None): + return docname + + def prepare_writing(self, docnames): + pass + + def write_doc(self, docname, doctree): + pass + """ +) + +_CONF_PY = textwrap.dedent( + """\ + from __future__ import annotations + + import sys + + sys.path.insert(0, r"__SCENARIO_SRCDIR__") + + extensions = [ + "sphinx_autodoc_sphinx", + ] + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Component reference + =================== + + .. toctree:: + + usage + + .. autobuilder:: demo_xref_builders.DemoXrefBuilder + """ +) + +_USAGE_RST = textwrap.dedent( + """\ + Usage + ===== + + See :sphinxext:builder:`DemoXrefBuilder` for the short form and + :sphinxext:builder:`demo_xref_builders.DemoXrefBuilder` for the + qualified form. + + This one dangles: :sphinxext:builder:`MissingBuilder`. + """ +) + + +@pytest.fixture(scope="module") +def sphinxext_xref_result( + tmp_path_factory: pytest.TempPathFactory, +) -> SharedSphinxResult: + """Build the two-page sphinxext-domain xref scenario.""" + cache_root = tmp_path_factory.mktemp("autodoc-sphinx-xref") + scenario = SphinxScenario( + files=( + ScenarioFile("demo_xref_builders.py", _MODULE_SOURCE), + ScenarioFile( + "conf.py", + _CONF_PY.replace("__SCENARIO_SRCDIR__", SCENARIO_SRCDIR_TOKEN), + substitute_srcdir=True, + ), + ScenarioFile("index.rst", _INDEX_RST), + ScenarioFile("usage.rst", _USAGE_RST), + ), + ) + return build_shared_sphinx_result( + cache_root, + scenario, + purge_modules=("demo_xref_builders",), + ) + + +def _xref_warnings(result: SharedSphinxResult) -> list[str]: + """Return xref-resolution warning lines from a build result. + + Filters narrowly for actual resolution failures so unrelated build + noise from earlier in-process Sphinx runs never false-matches. + """ + return [ + line + for line in result.warnings.splitlines() + if "reference target not found" in line.lower() + or "undefined label" in line.lower() + ] + + +@pytest.mark.integration +def test_sphinxext_xrefs_resolve_without_warnings( + sphinxext_xref_result: SharedSphinxResult, +) -> None: + """Resolvable :sphinxext:builder: refs produce no warnings.""" + offending = [ + line + for line in _xref_warnings(sphinxext_xref_result) + if "MissingBuilder" not in line + ] + assert offending == [], "Component cross-references produced warnings:\n" + ( + "\n".join(offending) + ) + + +@pytest.mark.integration +def test_dangling_sphinxext_xref_warns( + sphinxext_xref_result: SharedSphinxResult, +) -> None: + """A dangling :sphinxext:builder: ref warns, proving resolution runs.""" + dangling = [ + line + for line in _xref_warnings(sphinxext_xref_result) + if "MissingBuilder" in line + ] + assert len(dangling) == 1 + + +@pytest.mark.integration +def test_html_contains_resolved_component_links( + sphinxext_xref_result: SharedSphinxResult, +) -> None: + """Resolved refs become links pointing at the component anchor.""" + usage_html = read_output(sphinxext_xref_result, "usage.html") + assert 'href="index.html#sphinxext-builder' in usage_html + + +@pytest.mark.integration +def test_domain_data_populated_after_build( + sphinxext_xref_result: SharedSphinxResult, +) -> None: + """The documented builder lands in the sphinxext domain data.""" + domain_data = sphinxext_xref_result.app.env.domaindata["sphinxext"] + assert "demo_xref_builders.DemoXrefBuilder" in domain_data["builder"] From ae4d3a44eb7884613a90f59560c6178eaba91f25 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:36:03 -0500 Subject: [PATCH 10/33] gp-sphinx(autodoc-sphinx[autodomain]): Document Sphinx domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Issue #52 — domains are the final extension-point type. A domain's interesting surface (object types, roles, directives, indices, role prefix) lives in class-level dicts that plain autodoc renders as opaque attributes. what: - Add autodomain/autodomains directives discovering domains from recorded app.add_domain() calls with a Domain subclass-scan fallback; facts surface the registered name, label (str() unwraps the lazy gettext proxy), object types, roles, directives, and the domain's own indices - Badge group: filled lime "domain" kind badge plus an outlined domain-name badge (SAB.TYPE_DOMAIN, SAB.MOD_DOMAIN_NAME) - ("sphinxext", "domain") layout profile; DemoTopicDomain demo; examples page documents the docutils domain that sphinx-autodoc-docutils itself registers — the bulk form replaying a sibling package's setup() --- docs/_ext/sphinx_demo_builder.py | 23 ++ .../sphinx-autodoc-sphinx/examples.md | 23 +- .../src/sphinx_autodoc_sphinx/__init__.py | 14 ++ .../src/sphinx_autodoc_sphinx/_badges.py | 43 ++++ .../src/sphinx_autodoc_sphinx/_domains_doc.py | 224 ++++++++++++++++++ .../sphinx_ux_autodoc_layout/_transforms.py | 2 +- .../src/sphinx_ux_badges/_css.py | 2 + .../_static/css/sab_palettes.css | 23 ++ .../test_autodoc_sphinx_integration.py | 31 +++ tests/ext/autodoc_sphinx/test_components.py | 88 +++++++ 10 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_domains_doc.py diff --git a/docs/_ext/sphinx_demo_builder.py b/docs/_ext/sphinx_demo_builder.py index 1cdda912..9d412765 100644 --- a/docs/_ext/sphinx_demo_builder.py +++ b/docs/_ext/sphinx_demo_builder.py @@ -16,6 +16,9 @@ import typing as t from sphinx.builders import Builder +from sphinx.domains import Domain, ObjType +from sphinx.locale import _ +from sphinx.roles import XRefRole if t.TYPE_CHECKING: from collections.abc import Iterator, Set @@ -53,6 +56,21 @@ def write_doc(self, docname: str, doctree: nodes.document) -> None: """Skip per-document output; the demo archives nothing.""" +class DemoTopicDomain(Domain): + """Describe demo topics with one object type and matching role.""" + + name = "demotopic" + label = "Demo topics" + + object_types = { # noqa: RUF012 — matches upstream sphinx.domains.Domain shape + "topic": ObjType(_("topic"), "topic"), + } + + roles = { # noqa: RUF012 — XRefRole instances are safe to share across domains + "topic": XRefRole(), + } + + def setup(app: Sphinx) -> ExtensionMetadata: """Register the demo extension components with Sphinx. @@ -63,14 +81,19 @@ def setup(app: Sphinx) -> ExtensionMetadata: ... self.calls: list[tuple[str, object]] = [] ... def add_builder(self, cls: object) -> None: ... self.calls.append(("add_builder", cls)) + ... def add_domain(self, cls: object) -> None: + ... self.calls.append(("add_domain", cls)) >>> fake = FakeApp() >>> metadata = setup(fake) # type: ignore[arg-type] >>> ("add_builder", DemoArchiveBuilder) in fake.calls True + >>> ("add_domain", DemoTopicDomain) in fake.calls + True >>> metadata["parallel_read_safe"] True """ app.add_builder(DemoArchiveBuilder) + app.add_domain(DemoTopicDomain) return { "version": "0.0.0", "parallel_read_safe": True, diff --git a/docs/packages/sphinx-autodoc-sphinx/examples.md b/docs/packages/sphinx-autodoc-sphinx/examples.md index 12ee0835..17815106 100644 --- a/docs/packages/sphinx-autodoc-sphinx/examples.md +++ b/docs/packages/sphinx-autodoc-sphinx/examples.md @@ -41,8 +41,27 @@ Renders every builder a module registers via `setup()`: :no-index: ``` +### Document one demo domain + +Domains surface their registered name, label, object types, roles, and +indices: + +```{eval-rst} +.. autodomain:: sphinx_demo_builder.DemoTopicDomain +``` + +### Bulk domains demo + +The bulk form replays a package's `setup()` — here documenting the +`docutils` domain that `sphinx-autodoc-docutils` itself registers: + +```{eval-rst} +.. autodomains:: sphinx_autodoc_docutils + :no-index: +``` + ### Cross-referencing components Component entries register targets in the `sphinxext` domain, so prose -can link to them: {sphinxext:builder}`DemoArchiveBuilder` resolves to -the entry above. +can link to them: {sphinxext:builder}`DemoArchiveBuilder` and +{sphinxext:domain}`DemoTopicDomain` resolve to the entries above. diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py index 92b19882..7d592c8f 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -17,6 +17,13 @@ AutoconfigvalueDirective, AutoconfigvaluesDirective, ) +from sphinx_autodoc_sphinx._domains_doc import ( + AutoDomain, + AutoDomains, + DomainInfo, + discover_domain, + discover_domains, +) from sphinx_autodoc_sphinx.domain import ( SphinxExtComponentIndex, SphinxExtDomain, @@ -25,13 +32,18 @@ __all__ = [ "AutoBuilder", "AutoBuilders", + "AutoDomain", + "AutoDomains", "AutoconfigvalueDirective", "AutoconfigvaluesDirective", "BuilderInfo", + "DomainInfo", "SphinxExtComponentIndex", "SphinxExtDomain", "discover_builder", "discover_builders", + "discover_domain", + "discover_domains", "setup", ] @@ -81,6 +93,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_directive("autoconfigvalues", AutoconfigvaluesDirective) app.add_directive("autobuilder", AutoBuilder) app.add_directive("autobuilders", AutoBuilders) + app.add_directive("autodomain", AutoDomain) + app.add_directive("autodomains", AutoDomains) _static_dir = str(pathlib.Path(__file__).parent / "_static") diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py index 6cd7c179..c846c5ec 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py @@ -46,6 +46,49 @@ def build_config_badge_group(value: SphinxConfigValue) -> nodes.inline: ) +def build_domain_badge_group(domain_name: str = "") -> nodes.inline: + """Return header badges for one documented Sphinx domain. + + Parameters + ---------- + domain_name : str + The domain's registered name (its role prefix); rendered as an + outlined secondary badge when non-empty. + + Returns + ------- + nodes.inline + Badge group for the entry header. + + Examples + -------- + >>> group = build_domain_badge_group("argparse") + >>> "domain" in group.astext() + True + >>> "argparse" in group.astext() + True + >>> build_domain_badge_group("").astext() + 'domain' + """ + specs = [ + BadgeSpec( + "domain", + tooltip="Sphinx domain", + classes=(SAB.TYPE_DOMAIN,), + ), + ] + if domain_name: + specs.append( + BadgeSpec( + domain_name, + tooltip=f"Domain name: {domain_name}", + classes=(SAB.MOD_DOMAIN_NAME,), + fill="outline", + ), + ) + return build_badge_group_from_specs(specs, classes=[_GROUP_CLASS]) + + def build_builder_badge_group(output_format: str = "") -> nodes.inline: """Return header badges for one documented Sphinx builder. diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_domains_doc.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_domains_doc.py new file mode 100644 index 00000000..1e030351 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_domains_doc.py @@ -0,0 +1,224 @@ +"""Rendering directives for Sphinx domain documentation.""" + +from __future__ import annotations + +import inspect +import typing as t +from dataclasses import dataclass + +from docutils.parsers.rst import directives +from sphinx.domains import Domain +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_sphinx._badges import build_domain_badge_group +from sphinx_autodoc_sphinx._components import ( + component_classes, + component_summary, + import_component, + render_component_nodes, + replay_setup, +) +from sphinx_autodoc_sphinx._directives import _literal_paragraph +from sphinx_autodoc_sphinx.domain import DOMAIN +from sphinx_ux_autodoc_layout import ApiFactRow + +if t.TYPE_CHECKING: + from docutils import nodes + from sphinx.util.typing import OptionSpec + + +@dataclass(frozen=True) +class DomainInfo: + """Recorded metadata for one documented domain class. + + Examples + -------- + >>> from sphinx_autodoc_argparse.domain import ArgparseDomain + >>> info = DomainInfo(cls=ArgparseDomain, registered=True) + >>> info.qualified_name + 'sphinx_autodoc_argparse.domain.ArgparseDomain' + >>> info.domain_name + 'argparse' + """ + + cls: type[Domain] + registered: bool = False + + @property + def qualified_name(self) -> str: + """Return the fully-qualified dotted path for the class. + + Examples + -------- + >>> from sphinx_autodoc_argparse.domain import ArgparseDomain + >>> DomainInfo(cls=ArgparseDomain).qualified_name + 'sphinx_autodoc_argparse.domain.ArgparseDomain' + """ + return f"{self.cls.__module__}.{self.cls.__name__}" + + @property + def domain_name(self) -> str: + """Return the domain's registered name (the role prefix). + + Examples + -------- + >>> from sphinx_autodoc_argparse.domain import ArgparseDomain + >>> DomainInfo(cls=ArgparseDomain).domain_name + 'argparse' + """ + return str(self.cls.name) + + +def _domains_from_calls( + calls: list[tuple[str, tuple[object, ...], dict[str, object]]], +) -> list[DomainInfo]: + """Extract domain metadata from recorded ``add_domain`` calls. + + Examples + -------- + >>> from sphinx_autodoc_argparse.domain import ArgparseDomain + >>> infos = _domains_from_calls( + ... [ + ... ("add_domain", (ArgparseDomain,), {}), + ... ("add_directive", ("noise", object), {}), + ... ], + ... ) + >>> [(info.cls.__name__, info.registered) for info in infos] + [('ArgparseDomain', True)] + """ + infos: list[DomainInfo] = [] + seen: set[type[Domain]] = set() + for call_name, args, _kwargs in calls: + if call_name != "add_domain" or len(args) < 1: + continue + cls = args[0] + if not (inspect.isclass(cls) and issubclass(cls, Domain)): + continue + if cls in seen: + continue + seen.add(cls) + infos.append(DomainInfo(cls=cls, registered=True)) + return infos + + +def discover_domains(module_name: str) -> list[DomainInfo]: + """Return domains a module registers, or defines as a fallback. + + Replays the module's ``setup()`` against a recorder so domains + surface with their ``app.add_domain()`` registration; falls back + to scanning the module for public + :class:`~sphinx.domains.Domain` subclasses. + + Examples + -------- + >>> infos = discover_domains("sphinx_autodoc_docutils") + >>> [(info.cls.__name__, info.registered) for info in infos] + [('DocutilsDomain', True)] + + >>> infos = discover_domains("sphinx_autodoc_argparse.domain") + >>> [(info.cls.__name__, info.registered) for info in infos] + [('ArgparseDomain', False)] + + >>> discover_domains("sphinx_fonts") + [] + """ + recorder = replay_setup(module_name) + if recorder is not None: + infos = _domains_from_calls(recorder.calls) + if infos: + return infos + return [DomainInfo(cls=cls) for cls in component_classes(module_name, Domain)] + + +def discover_domain(path: str) -> DomainInfo: + """Return one domain from a fully-qualified dotted path. + + Examples + -------- + >>> info = discover_domain("sphinx_autodoc_argparse.domain.ArgparseDomain") + >>> info.domain_name + 'argparse' + """ + cls = t.cast("type[Domain]", import_component(path)) + for info in discover_domains(cls.__module__): + if info.cls is cls: + return info + return DomainInfo(cls=cls) + + +def _domain_fact_rows(info: DomainInfo) -> list[ApiFactRow]: + """Return shared fact rows for one autodocumented domain. + + Examples + -------- + >>> from sphinx_autodoc_argparse.domain import ArgparseDomain + >>> rows = _domain_fact_rows(DomainInfo(cls=ArgparseDomain)) + >>> [row.label for row in rows] # doctest: +NORMALIZE_WHITESPACE + ['Python path', 'Domain name', 'Label', 'Object types', 'Roles', + 'Directives', 'Indices'] + """ + cls = info.cls + object_types = ", ".join(sorted(cls.object_types)) or "—" + roles = ", ".join(sorted(cls.roles)) or "—" + domain_directives = ", ".join(sorted(cls.directives)) or "—" + indices = ", ".join(index.name for index in cls.indices) or "—" + return [ + ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow("Domain name", _literal_paragraph(info.domain_name or "—")), + # str() unwraps the lazy gettext proxy Sphinx domains use. + ApiFactRow("Label", _literal_paragraph(str(cls.label) or "—")), + ApiFactRow("Object types", _literal_paragraph(object_types)), + ApiFactRow("Roles", _literal_paragraph(roles)), + ApiFactRow("Directives", _literal_paragraph(domain_directives)), + ApiFactRow("Indices", _literal_paragraph(indices)), + ] + + +def _render_domain( + directive: SphinxDirective, + info: DomainInfo, + *, + no_index: bool = False, +) -> list[nodes.Node]: + """Render one domain entry through the shared component pipeline.""" + return render_component_nodes( + directive, + objtype=DOMAIN, + path=info.qualified_name, + summary=component_summary(info.cls), + fact_rows=_domain_fact_rows(info), + badge_group=build_domain_badge_group(info.domain_name), + no_index=no_index, + ) + + +class AutoDomain(SphinxDirective): + """Render documentation for a single domain class.""" + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + info = discover_domain(self.arguments[0]) + return _render_domain(self, info, no_index="no-index" in self.options) + + +class AutoDomains(SphinxDirective): + """Render documentation for every domain a package registers. + + Accepts either an extension package (whose ``setup()`` runs against + a recorder so each ``app.add_domain(cls)`` call surfaces) or a + domain-defining module (introspected for ``Domain`` subclasses). + """ + + required_arguments = 1 + has_content = False + option_spec: t.ClassVar[OptionSpec] = {"no-index": directives.flag} + + def run(self) -> list[nodes.Node]: + no_index = "no-index" in self.options + results: list[nodes.Node] = [] + for info in discover_domains(self.arguments[0]): + results.extend(_render_domain(self, info, no_index=no_index)) + return results diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py index ef3d2d46..2f361302 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_transforms.py @@ -86,7 +86,7 @@ "translator", ) -_MANAGED_SPHINXEXT_OBJTYPES: tuple[str, ...] = ("builder",) +_MANAGED_SPHINXEXT_OBJTYPES: tuple[str, ...] = ("builder", "domain") @dataclasses.dataclass(frozen=True, slots=True) diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py index 68e2e16b..0e6b1ac6 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_css.py @@ -175,9 +175,11 @@ class SAB: # ── Sphinx extension components (filled) ───────────── TYPE_BUILDER = "gp-sphinx-badge--type-builder" + TYPE_DOMAIN = "gp-sphinx-badge--type-domain" # ── Sphinx extension component modifiers (outlined) ── MOD_FORMAT = "gp-sphinx-badge--mod-format" + MOD_DOMAIN_NAME = "gp-sphinx-badge--mod-domain-name" # ── Package metadata (maturity + links) ─────────────── META_ALPHA = "gp-sphinx-badge--meta-alpha" diff --git a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css index b74269a0..7d317006 100644 --- a/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css +++ b/packages/sphinx-ux-badges/src/sphinx_ux_badges/_static/css/sab_palettes.css @@ -149,10 +149,17 @@ --gp-sphinx-badge-builder-fg: #b45309; --gp-sphinx-badge-builder-border: #f59e0b; + --gp-sphinx-badge-domain-bg: #f7fee7; + --gp-sphinx-badge-domain-fg: #4d7c0f; + --gp-sphinx-badge-domain-border: #84cc16; + /* ── Sphinx extension component modifiers (outlined) ── */ --gp-sphinx-badge-mod-format-fg: #6b7280; --gp-sphinx-badge-mod-format-border: #d1d5db; + --gp-sphinx-badge-mod-domain-name-fg: #6b7280; + --gp-sphinx-badge-mod-domain-name-border: #d1d5db; + /* ── Package metadata (maturity + links) ────────────── */ --gp-sphinx-badge-meta-alpha-bg: #ffedc6; --gp-sphinx-badge-meta-alpha-fg: #4e2009; @@ -292,9 +299,16 @@ --gp-sphinx-badge-builder-fg: #fcd34d; --gp-sphinx-badge-builder-border: #fbbf24; + --gp-sphinx-badge-domain-bg: #1a2e05; + --gp-sphinx-badge-domain-fg: #bef264; + --gp-sphinx-badge-domain-border: #a3e635; + --gp-sphinx-badge-mod-format-fg: #9ca3af; --gp-sphinx-badge-mod-format-border: #4b5563; + --gp-sphinx-badge-mod-domain-name-fg: #9ca3af; + --gp-sphinx-badge-mod-domain-name-border: #4b5563; + --gp-sphinx-badge-meta-alpha-bg: #3f2700; --gp-sphinx-badge-meta-alpha-fg: #ffca16; --gp-sphinx-badge-meta-alpha-border: #8f6424; @@ -434,9 +448,16 @@ body[data-theme="dark"] { --gp-sphinx-badge-builder-fg: #fcd34d; --gp-sphinx-badge-builder-border: #fbbf24; + --gp-sphinx-badge-domain-bg: #1a2e05; + --gp-sphinx-badge-domain-fg: #bef264; + --gp-sphinx-badge-domain-border: #a3e635; + --gp-sphinx-badge-mod-format-fg: #9ca3af; --gp-sphinx-badge-mod-format-border: #4b5563; + --gp-sphinx-badge-mod-domain-name-fg: #9ca3af; + --gp-sphinx-badge-mod-domain-name-border: #4b5563; + --gp-sphinx-badge-meta-alpha-bg: #3f2700; --gp-sphinx-badge-meta-alpha-fg: #ffca16; --gp-sphinx-badge-meta-alpha-border: #8f6424; @@ -523,11 +544,13 @@ body[data-theme="dark"] { * Colour classes — Sphinx extension components (filled) * ══════════════════════════════════════════════════════════ */ .gp-sphinx-badge--type-builder { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-builder-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-builder-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-builder-border); } +.gp-sphinx-badge--type-domain { --gp-sphinx-badge-bg: var(--gp-sphinx-badge-domain-bg); --gp-sphinx-badge-fg: var(--gp-sphinx-badge-domain-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-domain-border); } /* ══════════════════════════════════════════════════════════ * Colour classes — Sphinx extension component modifiers (outlined) * ══════════════════════════════════════════════════════════ */ .gp-sphinx-badge--mod-format { --gp-sphinx-badge-fg: var(--gp-sphinx-badge-mod-format-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-mod-format-border); } +.gp-sphinx-badge--mod-domain-name { --gp-sphinx-badge-fg: var(--gp-sphinx-badge-mod-domain-name-fg); --gp-sphinx-badge-border: var(--gp-sphinx-badge-mod-domain-name-border); } /* ══════════════════════════════════════════════════════════ * Colour classes — package metadata (maturity + links) diff --git a/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py index a9c8487b..f38a9808 100644 --- a/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py +++ b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py @@ -20,6 +20,8 @@ from __future__ import annotations from sphinx.builders import Builder + from sphinx.domains import Domain, ObjType + from sphinx.roles import XRefRole class DemoZipBuilder(Builder): @@ -43,7 +45,17 @@ def write_doc(self, docname, doctree): pass + class DemoRecipeDomain(Domain): + \"\"\"Describe demo recipes.\"\"\" + + name = "demorecipe" + label = "Demo recipes" + object_types = {"recipe": ObjType("recipe", "recipe")} + roles = {"recipe": XRefRole()} + + def setup(app): + app.add_domain(DemoRecipeDomain) app.add_config_value( "demo_option", True, @@ -89,6 +101,8 @@ def setup(app): .. autoconfigvalues:: demo_sphinx_ext .. autobuilder:: demo_sphinx_ext.DemoZipBuilder + + .. autodomain:: demo_sphinx_ext.DemoRecipeDomain """ ) @@ -156,3 +170,20 @@ def test_autodoc_sphinx_builder_entries( assert "demo-zip" in html assert "image/png" in html assert "Parallel-safe" in html + + +@pytest.mark.integration +def test_autodoc_sphinx_domain_entries( + autodoc_sphinx_html_result: SharedSphinxResult, +) -> None: + """autodomain entries render with profile, badges, and facts.""" + html = read_output(autodoc_sphinx_html_result, "index.html") + + assert "gp-sphinx-api-profile--sphinxext-domain" in html + assert ">domain<" in html + assert "gp-sphinx-badge--mod-domain-name" in html + assert ">demorecipe<" in html + assert "Object types" in html + assert ">recipe<" in html + # Literal body text splits into per-word chunks. + assert ">recipes<" in html diff --git a/tests/ext/autodoc_sphinx/test_components.py b/tests/ext/autodoc_sphinx/test_components.py index 7ffdc9c0..ef536dce 100644 --- a/tests/ext/autodoc_sphinx/test_components.py +++ b/tests/ext/autodoc_sphinx/test_components.py @@ -32,6 +32,13 @@ normalize_component_nodes, replay_setup, ) +from sphinx_autodoc_sphinx._domains_doc import ( + DomainInfo, + _domain_fact_rows, + _domains_from_calls, + discover_domain, + discover_domains, +) from sphinx_ux_autodoc_layout import ApiFactRow from sphinx_ux_autodoc_layout._nodes import api_component @@ -286,3 +293,84 @@ def write_doc(self, docname: str, doctree: nodes.document) -> None: assert by_label["Builder name"] == "—" assert by_label["Output format"] == "—" assert by_label["Supported image types"] == "—" + + +# --------------------------------------------------------------------------- +# Domains +# --------------------------------------------------------------------------- + + +def test_domains_from_calls_filters_domain_classes() -> None: + """_domains_from_calls keeps only Domain subclasses, deduped.""" + from sphinx_autodoc_argparse.domain import ArgparseDomain + + infos = _domains_from_calls( + [ + ("add_domain", (ArgparseDomain,), {}), + ("add_domain", (ArgparseDomain,), {"override": True}), + ("add_domain", (object,), {}), + ("add_directive", ("noise", object), {}), + ], + ) + assert [(info.cls.__name__, info.registered) for info in infos] == [ + ("ArgparseDomain", True), + ] + + +def test_discover_domains_via_setup_registration() -> None: + """discover_domains surfaces domains a package's setup() registers.""" + infos = discover_domains("sphinx_autodoc_docutils") + assert [(info.cls.__name__, info.registered) for info in infos] == [ + ("DocutilsDomain", True), + ] + + +def test_discover_domains_scan_fallback() -> None: + """discover_domains scans modules without a registering setup().""" + infos = discover_domains("sphinx_autodoc_argparse.domain") + assert [(info.cls.__name__, info.registered) for info in infos] == [ + ("ArgparseDomain", False), + ] + + +def test_discover_domains_empty_for_module_without_domains() -> None: + """discover_domains returns [] for modules without domains.""" + assert discover_domains("sphinx_fonts") == [] + + +def test_discover_domain_single_path() -> None: + """discover_domain imports one domain from a dotted path.""" + info = discover_domain("sphinx_autodoc_argparse.domain.ArgparseDomain") + assert info.domain_name == "argparse" + + +def test_domain_fact_rows_surface_metadata() -> None: + """Domain fact rows include name, label, surface dicts, and indices.""" + from sphinx_autodoc_argparse.domain import ArgparseDomain + + rows = _domain_fact_rows(DomainInfo(cls=ArgparseDomain)) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Domain name"] == "argparse" + assert by_label["Label"] == "Argparse CLI" + assert by_label["Object types"] == "option, positional, program, subcommand" + assert by_label["Roles"] == "option, positional, program, subcommand" + assert by_label["Directives"] == "—" + assert by_label["Indices"] == "programsindex, optionsindex" + + +def test_domain_fact_rows_dash_for_bare_domain() -> None: + """Domains without surface registrations degrade to dashes.""" + from sphinx.domains import Domain as BaseDomain + + class _BareDomain(BaseDomain): + """Domain leaving every base attribute untouched.""" + + name = "bare" + label = "Bare" + + rows = _domain_fact_rows(DomainInfo(cls=_BareDomain)) + by_label = {row.label: row.body.astext() for row in rows} + assert by_label["Object types"] == "—" + assert by_label["Roles"] == "—" + assert by_label["Directives"] == "—" + assert by_label["Indices"] == "—" From 055d0bfb9c9164f158723c754bd602548302f54d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:41:51 -0500 Subject: [PATCH 11/33] docs(packages[autodoc]): Reference pages and recipes for component autodoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The new component directives and cross-reference roles need a documented home — domain roles never appear in autodirectives output, so without a reference section they would be invisible API. what: - Add docs/packages/sphinx-autodoc-docutils/reference.md: the package documents its own twelve directives via autodirectives, plus a role table for the six {docutils:*} roles and the component index - Extend the sphinx-autodoc-sphinx reference page with the {sphinxext:*} role table and index link - Tutorial pages gain the component single/bulk pattern; how-to pages gain a cross-referencing recipe noting that :no-index: entries create no link target - Package READMEs describe the broadened component surface --- .../sphinx-autodoc-docutils/how-to.md | 14 +++++++ .../sphinx-autodoc-docutils/reference.md | 37 +++++++++++++++++++ .../sphinx-autodoc-docutils/tutorial.md | 20 ++++++++++ docs/packages/sphinx-autodoc-sphinx/how-to.md | 14 +++++++ .../sphinx-autodoc-sphinx/reference.md | 19 ++++++++++ .../sphinx-autodoc-sphinx/tutorial.md | 19 ++++++++++ packages/sphinx-autodoc-docutils/README.md | 27 +++++++++++++- packages/sphinx-autodoc-sphinx/README.md | 18 ++++++++- 8 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 docs/packages/sphinx-autodoc-docutils/reference.md diff --git a/docs/packages/sphinx-autodoc-docutils/how-to.md b/docs/packages/sphinx-autodoc-docutils/how-to.md index 0495481e..ea1232c1 100644 --- a/docs/packages/sphinx-autodoc-docutils/how-to.md +++ b/docs/packages/sphinx-autodoc-docutils/how-to.md @@ -11,3 +11,17 @@ extensions = ["sphinx_autodoc_docutils"] `sphinx_autodoc_docutils` automatically registers `sphinx_ux_badges`, `sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. You do not need to add them separately to your `extensions` list. + +## Cross-reference documented components + +Component entries register targets in the `docutils` domain, so prose +anywhere in the project can link to them: + +```md +See {docutils:transform}`SanitizeTransform` for the cleanup pass. +``` + +The entry being linked must be rendered **without** `:no-index:` — +no-index entries create no cross-reference target. Use the +fully-qualified dotted path when two components share a bare class +name. diff --git a/docs/packages/sphinx-autodoc-docutils/reference.md b/docs/packages/sphinx-autodoc-docutils/reference.md new file mode 100644 index 00000000..09c3a093 --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils/reference.md @@ -0,0 +1,37 @@ +(sphinx-autodoc-docutils-reference)= + +# API Reference + +## Directive reference + +Generated from `app.add_directive()` registrations in +[`sphinx_autodoc_docutils/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py) +via the package's own bulk directive — every `auto*` pair documents +itself. + +```{eval-rst} +.. autodirectives:: sphinx_autodoc_docutils +``` + +## Cross-reference roles + +The extension registers a `docutils` Sphinx domain. Every component +entry rendered without `:no-index:` becomes a link target for the +matching role: + +| Role | Links to | +| --- | --- | +| `` {docutils:transform}`Name` `` | `autotransform` / `autotransforms` entries | +| `` {docutils:reader}`Name` `` | `autoreader` / `autoreaders` entries | +| `` {docutils:parser}`Name` `` | `autoparser` / `autoparsers` entries | +| `` {docutils:writer}`Name` `` | `autowriter` / `autowriters` entries | +| `` {docutils:node}`Name` `` | `autonode` / `autonodes` entries | +| `` {docutils:translator}`Name` `` | `autotranslator` / `autotranslators` entries | + +Targets accept the fully-qualified dotted path +(`` {docutils:transform}`pkg.transforms.Sanitize` ``) or the bare class +name when it is unambiguous across the project. Dangling references +warn at build time. + +The domain also ships a grouped components index: +{ref}`docutils-componentindex`. diff --git a/docs/packages/sphinx-autodoc-docutils/tutorial.md b/docs/packages/sphinx-autodoc-docutils/tutorial.md index bcbfd320..470c0e6c 100644 --- a/docs/packages/sphinx-autodoc-docutils/tutorial.md +++ b/docs/packages/sphinx-autodoc-docutils/tutorial.md @@ -32,3 +32,23 @@ registers: .. autoroles:: my_project.docs_roles ``` ```` + +The same single/bulk pattern covers every docutils extension point — +transforms, readers, parsers, writers, custom nodes, and translators: + +````myst +```{eval-rst} +.. autotransform:: my_project.transforms.SanitizeTransform +``` +```` + +````myst +```{eval-rst} +.. autonodes:: my_project +``` +```` + +Bulk forms accept either an extension package (its `setup()` is +replayed so `app.add_transform()` / `app.add_node()` registrations +surface with their real metadata) or a plain module (scanned for +subclasses of the matching docutils base class). diff --git a/docs/packages/sphinx-autodoc-sphinx/how-to.md b/docs/packages/sphinx-autodoc-sphinx/how-to.md index 74b4806f..8e67879b 100644 --- a/docs/packages/sphinx-autodoc-sphinx/how-to.md +++ b/docs/packages/sphinx-autodoc-sphinx/how-to.md @@ -11,3 +11,17 @@ extensions = ["sphinx_autodoc_sphinx"] `sphinx_autodoc_sphinx` automatically registers `sphinx_ux_badges`, `sphinx_ux_autodoc_layout`, and `sphinx_autodoc_typehints_gp` via `app.setup_extension()`. You do not need to add them separately to your `extensions` list. + +## Cross-reference documented components + +Builder and domain entries register targets in the `sphinxext` +domain, so prose anywhere in the project can link to them: + +```md +Run {sphinxext:builder}`ZipBuilder` to bundle the site. +``` + +The entry being linked must be rendered **without** `:no-index:` — +no-index entries create no cross-reference target. Use the +fully-qualified dotted path when two components share a bare class +name. diff --git a/docs/packages/sphinx-autodoc-sphinx/reference.md b/docs/packages/sphinx-autodoc-sphinx/reference.md index fcd3027c..077bdcfe 100644 --- a/docs/packages/sphinx-autodoc-sphinx/reference.md +++ b/docs/packages/sphinx-autodoc-sphinx/reference.md @@ -13,3 +13,22 @@ directives. ```{eval-rst} .. autodirectives:: sphinx_autodoc_sphinx ``` + +## Cross-reference roles + +The extension registers a `sphinxext` Sphinx domain. Every component +entry rendered without `:no-index:` becomes a link target for the +matching role: + +| Role | Links to | +| --- | --- | +| `` {sphinxext:builder}`Name` `` | `autobuilder` / `autobuilders` entries | +| `` {sphinxext:domain}`Name` `` | `autodomain` / `autodomains` entries | + +Targets accept the fully-qualified dotted path +(`` {sphinxext:builder}`pkg.builders.ZipBuilder` ``) or the bare class +name when it is unambiguous across the project. Dangling references +warn at build time. + +The domain also ships a grouped components index: +{ref}`sphinxext-componentindex`. diff --git a/docs/packages/sphinx-autodoc-sphinx/tutorial.md b/docs/packages/sphinx-autodoc-sphinx/tutorial.md index 7d220fc7..eabf69a1 100644 --- a/docs/packages/sphinx-autodoc-sphinx/tutorial.md +++ b/docs/packages/sphinx-autodoc-sphinx/tutorial.md @@ -29,3 +29,22 @@ the same value and Sphinx warns on the duplicate ``confval``): :exclude: site_url ``` ```` + +Builders and domains follow the same single/bulk pattern: + +````myst +```{eval-rst} +.. autobuilder:: my_project.builders.ZipBuilder +``` +```` + +````myst +```{eval-rst} +.. autodomains:: my_project +``` +```` + +Bulk forms accept either an extension package (its `setup()` is +replayed so `app.add_builder()` / `app.add_domain()` registrations +surface) or a plain module (scanned for `Builder` / `Domain` +subclasses). diff --git a/packages/sphinx-autodoc-docutils/README.md b/packages/sphinx-autodoc-docutils/README.md index 29e0ebed..b7049afb 100644 --- a/packages/sphinx-autodoc-docutils/README.md +++ b/packages/sphinx-autodoc-docutils/README.md @@ -1,7 +1,8 @@ # sphinx-autodoc-docutils -Sphinx extension for turning docutils directives and roles into copyable -reference entries inside your docs site. +Sphinx extension for turning docutils components — directives, roles, +transforms, readers, parsers, writers, custom nodes, and translators — +into copyable reference entries inside your docs site. The extension keeps its semantic `rst:*` parse path, but the rendered body regions, badges, and shared type formatting now come from @@ -31,14 +32,36 @@ Then document directive classes and role callables with `eval-rst`: ``` ```` +Every docutils extension point gets the same single + bulk pair: + +```rst +.. autotransform:: my_project.transforms.SanitizeTransform + +.. autoreader:: my_project.readers.ArticleReader + +.. autoparser:: my_project.parsers.LineParser + +.. autowriter:: my_project.writers.PlainWriter + +.. autonode:: my_project.nodes.icon + +.. autotranslator:: my_project.writers.PlainTranslator +``` + For module-wide reference pages: ```rst .. autodirectives:: my_project.docs_ext .. autoroles:: my_project.docs_roles + +.. autotransforms:: my_project ``` +Component entries register targets in a `docutils` Sphinx domain, so +prose can cross-reference them with roles like +`` :docutils:transform:`SanitizeTransform` ``. + ## Documentation See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-docutils/) diff --git a/packages/sphinx-autodoc-sphinx/README.md b/packages/sphinx-autodoc-sphinx/README.md index c25b45e6..ee728852 100644 --- a/packages/sphinx-autodoc-sphinx/README.md +++ b/packages/sphinx-autodoc-sphinx/README.md @@ -1,7 +1,9 @@ # sphinx-autodoc-sphinx -Sphinx extension for documenting config values registered by -`app.add_config_value()` as copyable `conf.py` reference entries. +Sphinx extension for documenting the objects extensions register with +Sphinx — config values from `app.add_config_value()`, builders from +`app.add_builder()`, and domains from `app.add_domain()` — as copyable +reference entries. Rendered entries use the shared stack: `sphinx_ux_autodoc_layout` owns the visible `api-*` structure, `sphinx_ux_badges` owns badge output, and @@ -34,6 +36,18 @@ Or generate a full reference section for an extension module: .. autoconfigvalues:: sphinx_fonts ``` +Builders and domains follow the same single + bulk pattern: + +```rst +.. autobuilder:: my_project.builders.ZipBuilder + +.. autodomains:: my_project +``` + +Builder and domain entries register targets in a `sphinxext` Sphinx +domain, so prose can cross-reference them with roles like +`` :sphinxext:builder:`ZipBuilder` ``. + ## Documentation See the [full documentation](https://gp-sphinx.git-pull.com/packages/sphinx-autodoc-sphinx/) From 5d25e49cc1b6c49178f3839004fb3062be14ae8a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:43:38 -0500 Subject: [PATCH 12/33] docs(CHANGES) Component autodoc for docutils and Sphinx extension points why: Issue #52 ships three user-visible deliverables in the forthcoming release: the six docutils component autodoc pairs, the builder/domain pairs, and the cross-reference domains. what: - Add three What's new deliverables to the unreleased entry with {ref} links to the live demo and reference pages --- CHANGES | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/CHANGES b/CHANGES index 4c88b116..75772343 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,47 @@ $ uv add gp-sphinx --prerelease allow +### What's new + +#### Component autodoc for every docutils extension point + +`sphinx-autodoc-docutils` now documents the full docutils component +family with the same single + bulk-by-module pattern that +`autodirective`/`autodirectives` established: `autotransform(s)`, +`autoreader(s)`, `autoparser(s)`, `autowriter(s)`, `autonode(s)`, and +`autotranslator(s)`. Each entry renders with the shared card layout, a +per-type kind badge, and registry-aware facts — a transform's +`default_priority` and registration phase, a writer's resolved +translator class, a node's element categories and per-builder +visit/depart handlers, a translator's own handler overrides. Bulk +forms replay a package's `setup()` so Sphinx-side registrations +(`add_transform`, `add_node`, `add_source_parser`, `set_translator`) +surface with their real metadata, and fall back to module scans for +components that are instantiated directly — discovery never breaks the +build when a component needs framework state to construct. See +{ref}`sphinx-autodoc-docutils-examples` for live demos. (#53) + +#### Builder and domain autodoc + +`sphinx-autodoc-sphinx` gains `autobuilder(s)` and `autodomain(s)`, +documenting `sphinx.builders.Builder` and `sphinx.domains.Domain` +subclasses beside the config values it already covers. Builder entries +surface the CLI name, output format, supported image types, default +translator, and parallel-safety; domain entries surface the registered +name, label, object types, roles, directives, and indices. See +{ref}`sphinx-autodoc-sphinx-examples` for live demos. (#53) + +#### Cross-reference roles for documented components + +Two new Sphinx domains make every component entry linkable: the +`docutils` domain (`` {docutils:transform}`SanitizeTransform` `` and +siblings for readers, parsers, writers, nodes, and translators) and +the `sphinxext` domain (`` {sphinxext:builder}`...` ``, +`` {sphinxext:domain}`...` ``). Targets accept fully-qualified dotted +paths or unambiguous bare class names, dangling references warn at +build time, and each domain ships a grouped components index. See +{ref}`sphinx-autodoc-docutils-reference` for the role tables. (#53) + ### Fixes #### Code comments no longer trigger a font swap on first paint From ffa7af21fce7f75be07cbf0913df2223d36bf380 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 11:24:48 -0500 Subject: [PATCH 13/33] gp-sphinx(autodoc-docutils[badges]): Doctest build_kind_badge_group why: All functions need working doctests; this one predates the branch but its two new siblings in the same file are doctested, so the gap stood out in review (PR #53). what: - Add Examples covering a mapped kind, a component kind, and the unknown-kind fallback to the directive colour class --- .../src/sphinx_autodoc_docutils/_badges.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py index 217d823b..a34a1f06 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_badges.py @@ -33,6 +33,19 @@ def build_kind_badge_group(kind: str) -> nodes.inline: ------- nodes.inline Badge group for the entry header. + + Examples + -------- + >>> "directive" in build_kind_badge_group("directive").astext() + True + >>> "reader" in build_kind_badge_group("reader").astext() + True + + Unknown kinds keep their label and fall back to the directive + colour class: + + >>> "mystery" in build_kind_badge_group("mystery").astext() + True """ colour_class = _KIND_CLASSES.get(kind, SAB.TYPE_DIRECTIVE) return build_badge_group_from_specs( From 827340aa91c401920c5c2d1e172871c4b863379e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 11:26:14 -0500 Subject: [PATCH 14/33] gp-sphinx(test[badges]): Guard palette dark-block parity why: Review on PR #53 caught four badge families whose dark tokens landed in only one of the two dark-mode blocks, and one outlined modifier with no dark overrides at all. The stylesheet documents "Identical values" between its dark blocks but nothing enforced it, so the drift was invisible until someone diffed the blocks by hand. what: - Add tests/ext/badges/test_palettes.py parsing sab_palettes.css as text and asserting three contracts: the @media and body[data-theme="dark"] blocks declare identical token/value pairs; every var() a colour class reads resolves to a :root declaration; every light-mode token family has dark-mode coverage (family-level, so state-deprecated's transparent light-only -bg stays legal) --- tests/ext/badges/test_palettes.py | 114 ++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/ext/badges/test_palettes.py diff --git a/tests/ext/badges/test_palettes.py b/tests/ext/badges/test_palettes.py new file mode 100644 index 00000000..657e6709 --- /dev/null +++ b/tests/ext/badges/test_palettes.py @@ -0,0 +1,114 @@ +"""Contract tests for the shared badge palette stylesheet. + +``sab_palettes.css`` declares its own invariants: the +``body[data-theme="dark"]`` block holds "Identical values to the +``@media`` block above", and every colour class reads tokens that the +light-mode ``:root`` block defines. Drift between the blocks is +invisible at build time, so these tests parse the stylesheet as text +and pin the contracts down. +""" + +from __future__ import annotations + +import pathlib +import re + +import sphinx_ux_badges + +_PALETTES_PATH = ( + pathlib.Path(sphinx_ux_badges.__file__).parent + / "_static" + / "css" + / "sab_palettes.css" +) + +_DECLARATION = re.compile(r"(--gp-sphinx-badge-[\w-]+):\s*([^;]+);") +_VAR_REFERENCE = re.compile(r"var\((--gp-sphinx-badge-[\w-]+)\)") +_TOKEN_SUFFIX = re.compile(r"-(bg|fg|border)$") + +_ROOT_BLOCK = re.compile(r":root \{(.*?)\n\}", re.S) +_MEDIA_DARK_BLOCK = re.compile( + r"@media \(prefers-color-scheme: dark\) \{(.*?)\n\}\n", + re.S, +) +_BODY_DARK_BLOCK = re.compile(r'\nbody\[data-theme="dark"\] \{(.*?)\n\}', re.S) + + +def _palette_css() -> str: + """Return the palette stylesheet source.""" + return _PALETTES_PATH.read_text(encoding="utf-8") + + +def _block(css: str, pattern: re.Pattern[str]) -> str: + """Return the body of the first block matching *pattern*.""" + match = pattern.search(css) + assert match is not None, f"palette block not found: {pattern.pattern}" + return match.group(1) + + +def _declarations(block: str) -> dict[str, str]: + """Return ``token -> value`` declarations inside *block*.""" + return {token: value.strip() for token, value in _DECLARATION.findall(block)} + + +def _families(tokens: dict[str, str]) -> set[str]: + """Return token families (the prefix before ``-bg``/``-fg``/``-border``).""" + return {_TOKEN_SUFFIX.sub("", token) for token in tokens} + + +def test_dark_blocks_are_identical() -> None: + """The two dark-mode blocks declare identical token/value pairs. + + The ``body[data-theme="dark"]`` block's own comment promises + "Identical values to the ``@media`` block above"; a token present + in only one block silently renders light-mode colours for one of + the two dark-mode entry paths. + """ + css = _palette_css() + media_dark = _declarations(_block(css, _MEDIA_DARK_BLOCK)) + body_dark = _declarations(_block(css, _BODY_DARK_BLOCK)) + + assert media_dark == body_dark, ( + "dark-mode palette blocks diverged; " + f"only in @media: {sorted(set(media_dark) - set(body_dark))}, " + f"only in body[data-theme]: {sorted(set(body_dark) - set(media_dark))}" + ) + + +def test_colour_classes_reference_defined_tokens() -> None: + """Every var() a colour class reads resolves to a :root declaration.""" + css = _palette_css() + root = _declarations(_block(css, _ROOT_BLOCK)) + referenced = set(_VAR_REFERENCE.findall(css)) + + undefined = sorted( + token + for token in referenced + if token not in root + # The base bg/fg/border slots are set BY the colour classes + # and consumed by the structural layer, not declared in :root. + and token + not in { + "--gp-sphinx-badge-bg", + "--gp-sphinx-badge-fg", + "--gp-sphinx-badge-border", + } + ) + assert undefined == [], f"colour classes reference undefined tokens: {undefined}" + + +def test_root_token_families_have_dark_coverage() -> None: + """Every light-mode token family has dark-mode declarations. + + Family-level, not token-level: ``state-deprecated`` legitimately + declares a transparent ``-bg`` only in light mode, but its family + still carries dark ``-fg``/``-border`` overrides. A family absent + from the dark blocks entirely keeps light-mode colours in dark + mode. + """ + css = _palette_css() + root_families = _families(_declarations(_block(css, _ROOT_BLOCK))) + dark_families = _families(_declarations(_block(css, _MEDIA_DARK_BLOCK))) + + uncovered = sorted(root_families - dark_families) + assert uncovered == [], f"token families without dark-mode coverage: {uncovered}" From 58ea3e0b992eb7a81e056fb9b77413871492bd58 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 11:43:08 -0500 Subject: [PATCH 15/33] gp-sphinx(autodoc-sphinx[badges]): Doctest build_config_badge_group why: All functions need working doctests; this one predates the branch but its two new siblings in the same file are doctested, so the gap stood out in the follow-up review of PR #53. what: - Add Examples covering the config kind badge, the rebuild-mode badge, and the empty-rebuild fallback to "none" --- .../src/sphinx_autodoc_sphinx/_badges.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py index c846c5ec..0ee5d4f7 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_badges.py @@ -26,6 +26,22 @@ def build_config_badge_group(value: SphinxConfigValue) -> nodes.inline: ------- nodes.inline Badge group containing the config kind and rebuild mode badges. + + Examples + -------- + >>> from sphinx_autodoc_sphinx._directives import SphinxConfigValue + >>> value = SphinxConfigValue("demo_ext", "demo_option", True, "html", (bool,)) + >>> group = build_config_badge_group(value) + >>> "config" in group.astext() + True + >>> "html" in group.astext() + True + + An empty rebuild mode renders as ``none``: + + >>> bare = SphinxConfigValue("demo_ext", "demo_option", None, "") + >>> "none" in build_config_badge_group(bare).astext() + True """ rebuild = value.rebuild or "none" return build_badge_group_from_specs( From 98236240fb4ff34b127e725beedf7b2333e5457e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 12:36:16 -0500 Subject: [PATCH 16/33] gp-sphinx(ux-layout[inline]): Linked-literal and chip-list helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Fact rows render list values as one comma-joined literal blob, and names that have documented py-domain targets (classes, methods) render as dead text. Both autodoc packages need the same fix, so the helpers live in the shared layout package. what: - Add build_linked_literal(): a literal chip wrapped in a py-domain "obj" pending_xref with refwarn off — resolves when a target exists (ReferencesResolver defaults refdoc, fully-qualified targets match in exact-match mode) and silently stays a literal when it does not, so externals without an intersphinx inventory degrade cleanly - Add build_chip_paragraph(): one literal chip per value joined by ", " text nodes, em dash for empty value lists --- .../src/sphinx_ux_autodoc_layout/__init__.py | 6 + .../src/sphinx_ux_autodoc_layout/_inline.py | 128 ++++++++++++++++++ tests/ext/layout/test_inline.py | 126 +++++++++++++++++ 3 files changed, 260 insertions(+) create mode 100644 packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_inline.py create mode 100644 tests/ext/layout/test_inline.py diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py index 2af6403d..09fdf528 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py @@ -19,6 +19,10 @@ from sphinx_ux_autodoc_layout._cards import build_api_card_entry from sphinx_ux_autodoc_layout._css import API +from sphinx_ux_autodoc_layout._inline import ( + build_chip_paragraph, + build_linked_literal, +) from sphinx_ux_autodoc_layout._nodes import ( api_component, api_fold, @@ -79,6 +83,8 @@ "build_api_slot", "build_api_summary_section", "build_api_table_section", + "build_chip_paragraph", + "build_linked_literal", "inject_signature_slots", "is_viewcode_ref", "iter_desc_nodes", diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_inline.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_inline.py new file mode 100644 index 00000000..3fb25382 --- /dev/null +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/_inline.py @@ -0,0 +1,128 @@ +"""Inline value rendering for shared fact rows. + +Fact bodies frequently hold lists of short code values (output formats, +transform classes, overridden handlers). Rendering them as one +comma-joined literal produces a single wrapping blob and makes every +name dead text. These helpers render each value as its own literal +"chip" and, where a value names a documented Python object, wrap the +chip in a py-domain cross-reference that resolves when a target exists +and silently stays a literal when it does not. +""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from sphinx import addnodes + +if t.TYPE_CHECKING: + from collections.abc import Sequence + +#: Em dash rendered when a fact has no values. +EMPTY_VALUE = "—" + + +def build_linked_literal( + target: str, + display: str | None = None, +) -> addnodes.pending_xref: + """Return a literal chip wrapped in a py-domain cross-reference. + + The reference resolves against any documented Python object + (``reftype="obj"`` covers classes, methods, functions, …) using the + fully-qualified *target*. When no target exists the literal renders + unchanged and, because ``refwarn`` stays false, no warning is + emitted — externals like docutils base classes (which publish no + intersphinx inventory) degrade gracefully. + + Parameters + ---------- + target : str + Fully-qualified dotted path to resolve against. + display : str | None + Chip text; defaults to *target*. + + Returns + ------- + addnodes.pending_xref + Cross-reference wrapping a single literal chip. + + Examples + -------- + >>> xref = build_linked_literal("pkg.mod.Cls") + >>> xref["reftarget"] + 'pkg.mod.Cls' + >>> xref["refdomain"], xref["reftype"], xref["refwarn"] + ('py', 'obj', False) + >>> xref.astext() + 'pkg.mod.Cls' + + >>> build_linked_literal("pkg.mod.Cls.visit_table", "visit_table").astext() + 'visit_table' + """ + text = display if display is not None else target + literal = nodes.literal( + "", + "", + nodes.Text(text), + classes=["xref", "py", "py-obj"], + ) + return addnodes.pending_xref( + "", + literal, + refdomain="py", + reftype="obj", + reftarget=target, + refexplicit=display is not None, + refwarn=False, + ) + + +def build_chip_paragraph( + items: Sequence[nodes.Node | str], +) -> nodes.paragraph: + """Return a paragraph of comma-separated inline chips. + + Strings become plain literal chips; nodes (e.g. from + :func:`build_linked_literal`) are inserted as-is. An empty sequence + renders a single em-dash literal so callers keep the shared + "no value" presentation. + + Parameters + ---------- + items : Sequence[nodes.Node | str] + Chip values in display order. + + Returns + ------- + nodes.paragraph + Paragraph holding the chips. + + Examples + -------- + >>> paragraph = build_chip_paragraph(["html5", "xhtml", "html"]) + >>> paragraph.astext() + 'html5, xhtml, html' + >>> sum(isinstance(child, nodes.literal) for child in paragraph.children) + 3 + + >>> build_chip_paragraph([]).astext() + '—' + + >>> mixed = build_chip_paragraph([build_linked_literal("pkg.Cls"), "raw"]) + >>> mixed.astext() + 'pkg.Cls, raw' + """ + paragraph = nodes.paragraph() + if not items: + paragraph += nodes.literal(EMPTY_VALUE, EMPTY_VALUE) + return paragraph + for index, item in enumerate(items): + if index: + paragraph += nodes.Text(", ") + if isinstance(item, str): + paragraph += nodes.literal(item, item) + else: + paragraph += item + return paragraph diff --git a/tests/ext/layout/test_inline.py b/tests/ext/layout/test_inline.py new file mode 100644 index 00000000..f70d9d3e --- /dev/null +++ b/tests/ext/layout/test_inline.py @@ -0,0 +1,126 @@ +"""Unit tests for the shared inline fact-value helpers.""" + +from __future__ import annotations + +import typing as t + +import pytest +from docutils import nodes +from sphinx import addnodes + +from sphinx_ux_autodoc_layout import build_chip_paragraph, build_linked_literal + + +class LinkedLiteralCase(t.NamedTuple): + """Test case for build_linked_literal().""" + + test_id: str + target: str + display: str | None + expected_text: str + expected_explicit: bool + + +_LINKED_LITERAL_CASES: list[LinkedLiteralCase] = [ + LinkedLiteralCase( + test_id="target_as_display", + target="pkg.mod.Cls", + display=None, + expected_text="pkg.mod.Cls", + expected_explicit=False, + ), + LinkedLiteralCase( + test_id="bare_name_display", + target="pkg.mod.Cls", + display="Cls", + expected_text="Cls", + expected_explicit=True, + ), + LinkedLiteralCase( + test_id="method_target", + target="pkg.mod.Cls.visit_table", + display="visit_table", + expected_text="visit_table", + expected_explicit=True, + ), +] + + +@pytest.mark.parametrize( + "case", + _LINKED_LITERAL_CASES, + ids=lambda c: c.test_id, +) +def test_build_linked_literal(case: LinkedLiteralCase) -> None: + """build_linked_literal wraps a literal chip in a py-obj xref.""" + xref = build_linked_literal(case.target, case.display) + assert isinstance(xref, addnodes.pending_xref) + assert xref["refdomain"] == "py" + assert xref["reftype"] == "obj" + assert xref["reftarget"] == case.target + assert xref["refwarn"] is False + assert xref["refexplicit"] is case.expected_explicit + assert xref.astext() == case.expected_text + literal = xref.children[0] + assert isinstance(literal, nodes.literal) + assert "xref" in literal["classes"] + + +def test_build_linked_literal_no_refspecific() -> None: + """Fully-qualified targets resolve in exact-match mode (searchmode 0).""" + xref = build_linked_literal("pkg.mod.Cls") + assert not xref.hasattr("refspecific") + + +class ChipParagraphCase(t.NamedTuple): + """Test case for build_chip_paragraph().""" + + test_id: str + items: list[str] + expected_text: str + expected_literals: int + + +_CHIP_PARAGRAPH_CASES: list[ChipParagraphCase] = [ + ChipParagraphCase( + test_id="three_strings", + items=["html5", "xhtml", "html"], + expected_text="html5, xhtml, html", + expected_literals=3, + ), + ChipParagraphCase( + test_id="single_string", + items=["standalone"], + expected_text="standalone", + expected_literals=1, + ), + ChipParagraphCase( + test_id="empty_renders_dash", + items=[], + expected_text="—", + expected_literals=1, + ), +] + + +@pytest.mark.parametrize( + "case", + _CHIP_PARAGRAPH_CASES, + ids=lambda c: c.test_id, +) +def test_build_chip_paragraph(case: ChipParagraphCase) -> None: + """build_chip_paragraph renders one literal chip per item.""" + paragraph = build_chip_paragraph(list(case.items)) + assert paragraph.astext() == case.expected_text + literal_count = sum( + isinstance(child, nodes.literal) for child in paragraph.children + ) + assert literal_count == case.expected_literals + + +def test_build_chip_paragraph_accepts_nodes() -> None: + """Pre-built nodes (e.g. linked literals) pass through unchanged.""" + xref = build_linked_literal("pkg.Cls") + paragraph = build_chip_paragraph([xref, "plain"]) + assert paragraph.children[0] is xref + assert paragraph.astext() == "pkg.Cls, plain" From c27238f68d2eff83ba5458324db35c89b08a0d32 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 12:40:27 -0500 Subject: [PATCH 17/33] gp-sphinx(autodoc-docutils[facts]): Link and un-clump component facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Every fact value rendered as one comma-joined literal blob — formats, transform sets, and translator overrides wrapped as a single chip, and names with real py-domain targets (a consumer's automodule pages document the same classes and methods) were dead text. what: - Render list-valued facts (supported formats/aliases, categories, handler builders) as individual literal chips - Cross-link name-valued facts via the shared linked-literal helper: Python path on all six types, writer translator class, translator base class and per-method overrides (fully-qualified method targets), node base classes, and reader/writer transform sets (bare-name chips targeting qualified paths) - Replace safe_transform_names() with safe_transform_classes() so chips can carry qualified targets; add transform_chip_nodes() and linked_paragraph() to the shared component helpers - Integration: the xref scenario now automodules the demo module and asserts the Python-path fact resolves to the autodoc anchor; unresolvable chips stay literals with no warnings (covered by the existing resolve-clean assertion) --- .../sphinx_autodoc_docutils/_components.py | 51 +++++++++++++++--- .../src/sphinx_autodoc_docutils/_nodes_doc.py | 29 ++++++---- .../sphinx_autodoc_docutils/_parsers_doc.py | 10 ++-- .../sphinx_autodoc_docutils/_readers_doc.py | 13 +++-- .../_transforms_doc.py | 3 +- .../_translators_doc.py | 22 ++++++-- .../sphinx_autodoc_docutils/_writers_doc.py | 25 ++++----- tests/ext/autodoc_docutils/test_components.py | 54 +++++++++++++++++++ .../test_domain_xref_integration.py | 21 ++++++++ 9 files changed, 179 insertions(+), 49 deletions(-) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py index 1aa05f9a..eaa39d6c 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py @@ -29,6 +29,8 @@ ) from sphinx_ux_autodoc_layout import ( build_api_facts_section, + build_chip_paragraph, + build_linked_literal, inject_signature_slots, iter_desc_nodes, parse_generated_markup, @@ -214,8 +216,8 @@ def render_component_nodes( return node_list -def safe_transform_names(component_cls: type) -> list[str]: - """Return transform class names from ``cls().get_transforms()``, guarded. +def safe_transform_classes(component_cls: type) -> list[type]: + """Return transform classes from ``cls().get_transforms()``, guarded. Readers and writers expose their transform set through ``get_transforms()`` on an *instance*; real-world components (e.g. @@ -226,18 +228,55 @@ def safe_transform_names(component_cls: type) -> list[str]: Examples -------- >>> from docutils.readers.standalone import Reader - >>> names = safe_transform_names(Reader) - >>> "Transitions" in names + >>> classes = safe_transform_classes(Reader) + >>> any(cls.__name__ == "Transitions" for cls in classes) True - >>> safe_transform_names(object) + >>> safe_transform_classes(object) [] """ try: transforms = component_cls().get_transforms() except Exception: # noqa: BLE001 — degrade to no facts on any component error return [] - return [cls.__name__ for cls in transforms] + return list(transforms) + + +def linked_paragraph(target: str, display: str | None = None) -> nodes.paragraph: + """Return a paragraph holding one linked literal chip. + + Examples + -------- + >>> linked_paragraph("pkg.mod.Cls").astext() + 'pkg.mod.Cls' + >>> linked_paragraph("pkg.mod.Cls", "Cls").astext() + 'Cls' + """ + return build_chip_paragraph([build_linked_literal(target, display)]) + + +def transform_chip_nodes(component_cls: type) -> list[nodes.Node]: + """Return linked chips for a component's transform set. + + Each chip displays the bare class name and cross-references the + fully-qualified path, so transforms documented anywhere in the + project become links while docutils-internal transforms stay plain + chips. + + Examples + -------- + >>> from docutils.readers.standalone import Reader + >>> chips = transform_chip_nodes(Reader) + >>> any(chip.astext() == "Transitions" for chip in chips) + True + """ + return [ + build_linked_literal( + f"{transform_cls.__module__}.{transform_cls.__qualname__}", + transform_cls.__name__, + ) + for transform_cls in safe_transform_classes(component_cls) + ] def import_component(path: str) -> type: diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py index 2b3e0b0c..4bf81e18 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py @@ -14,15 +14,19 @@ from sphinx_autodoc_docutils._components import ( component_classes, import_component, + linked_paragraph, render_component_nodes, ) from sphinx_autodoc_docutils._directives import ( - _literal_paragraph, _summary, replay_setup, ) from sphinx_autodoc_docutils.domain import NODE -from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout import ( + ApiFactRow, + build_chip_paragraph, + build_linked_literal, +) if t.TYPE_CHECKING: from sphinx.util.typing import OptionSpec @@ -190,14 +194,21 @@ def _node_fact_rows(info: NodeInfo) -> list[ApiFactRow]: >>> [row.label for row in rows] ['Python path', 'Base classes', 'Categories', 'Visit/depart handlers'] """ - bases = ", ".join(base.__name__ for base in info.cls.__bases__) - categories = ", ".join(node_categories(info.cls)) or "—" - handlers = ", ".join(info.handlers) or "—" + base_chips: list[nodes.Node] = [ + build_linked_literal( + f"{base.__module__}.{base.__qualname__}", + base.__name__, + ) + for base in info.cls.__bases__ + ] return [ - ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), - ApiFactRow("Base classes", _literal_paragraph(bases)), - ApiFactRow("Categories", _literal_paragraph(categories)), - ApiFactRow("Visit/depart handlers", _literal_paragraph(handlers)), + ApiFactRow("Python path", linked_paragraph(info.qualified_name)), + ApiFactRow("Base classes", build_chip_paragraph(base_chips)), + ApiFactRow("Categories", build_chip_paragraph(node_categories(info.cls))), + ApiFactRow( + "Visit/depart handlers", + build_chip_paragraph(list(info.handlers)), + ), ] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py index 6ac2f8ce..3e7c0802 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py @@ -14,6 +14,7 @@ from sphinx_autodoc_docutils._components import ( component_classes, import_component, + linked_paragraph, render_component_nodes, ) from sphinx_autodoc_docutils._directives import ( @@ -22,7 +23,7 @@ replay_setup, ) from sphinx_autodoc_docutils.domain import PARSER -from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout import ApiFactRow, build_chip_paragraph if t.TYPE_CHECKING: from docutils import nodes @@ -161,11 +162,8 @@ def _parser_fact_rows(info: ParserInfo) -> list[ApiFactRow]: ['Python path', 'Supported aliases', 'Config section'] """ rows = [ - ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), - ApiFactRow( - "Supported aliases", - _literal_paragraph(", ".join(info.aliases) or "—"), - ), + ApiFactRow("Python path", linked_paragraph(info.qualified_name)), + ApiFactRow("Supported aliases", build_chip_paragraph(list(info.aliases))), ApiFactRow( "Config section", _literal_paragraph(info.cls.config_section or "—"), diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py index d501fe51..afd36db4 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py @@ -12,12 +12,13 @@ from sphinx_autodoc_docutils._components import ( component_classes, import_component, + linked_paragraph, render_component_nodes, - safe_transform_names, + transform_chip_nodes, ) from sphinx_autodoc_docutils._directives import _literal_paragraph, _summary from sphinx_autodoc_docutils.domain import READER -from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout import ApiFactRow, build_chip_paragraph if t.TYPE_CHECKING: from docutils import nodes @@ -64,19 +65,17 @@ def _reader_fact_rows(cls: type[Reader[t.Any]]) -> list[ApiFactRow]: >>> [row.label for row in rows] ['Python path', 'Supported formats', 'Config section', 'Transforms'] """ - supported = ", ".join(cls.supported) or "—" - transforms = ", ".join(safe_transform_names(cls)) or "—" return [ ApiFactRow( "Python path", - _literal_paragraph(f"{cls.__module__}.{cls.__name__}"), + linked_paragraph(f"{cls.__module__}.{cls.__name__}"), ), - ApiFactRow("Supported formats", _literal_paragraph(supported)), + ApiFactRow("Supported formats", build_chip_paragraph(list(cls.supported))), ApiFactRow( "Config section", _literal_paragraph(cls.config_section or "—"), ), - ApiFactRow("Transforms", _literal_paragraph(transforms)), + ApiFactRow("Transforms", build_chip_paragraph(transform_chip_nodes(cls))), ] diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py index 91b21933..e148d91b 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py @@ -14,6 +14,7 @@ from sphinx_autodoc_docutils._components import ( component_classes, import_component, + linked_paragraph, render_component_nodes, ) from sphinx_autodoc_docutils._directives import ( @@ -165,7 +166,7 @@ def _transform_fact_rows(info: TransformInfo) -> list[ApiFactRow]: ['Python path', 'Default priority', 'Registered via'] """ rows = [ - ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow("Python path", linked_paragraph(info.qualified_name)), ApiFactRow( "Default priority", _literal_paragraph( diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py index 379191e0..2286d2f3 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py @@ -14,6 +14,7 @@ from sphinx_autodoc_docutils._components import ( component_classes, import_component, + linked_paragraph, render_component_nodes, ) from sphinx_autodoc_docutils._directives import ( @@ -22,7 +23,11 @@ replay_setup, ) from sphinx_autodoc_docutils.domain import TRANSLATOR -from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout import ( + ApiFactRow, + build_chip_paragraph, + build_linked_literal, +) if t.TYPE_CHECKING: from sphinx.util.typing import OptionSpec @@ -169,14 +174,21 @@ def _translator_fact_rows(info: TranslatorInfo) -> list[ApiFactRow]: >>> [row.label for row in rows] ['Python path', 'Base class', 'Overrides'] """ - overrides = ", ".join(translator_overrides(info.cls)) or "—" + override_chips: list[nodes.Node] = [ + build_linked_literal(f"{info.qualified_name}.{method}", method) + for method in translator_overrides(info.cls) + ] + base = info.cls.__bases__[0] rows = [ - ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow("Python path", linked_paragraph(info.qualified_name)), ApiFactRow( "Base class", - _literal_paragraph(info.cls.__bases__[0].__name__), + linked_paragraph( + f"{base.__module__}.{base.__qualname__}", + base.__name__, + ), ), - ApiFactRow("Overrides", _literal_paragraph(overrides)), + ApiFactRow("Overrides", build_chip_paragraph(override_chips)), ] if info.builder_name: rows.append( diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py index 69c1280d..4c53b2b6 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py @@ -13,12 +13,13 @@ from sphinx_autodoc_docutils._components import ( component_classes, import_component, + linked_paragraph, render_component_nodes, - safe_transform_names, + transform_chip_nodes, ) from sphinx_autodoc_docutils._directives import _literal_paragraph, _summary from sphinx_autodoc_docutils.domain import WRITER -from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout import ApiFactRow, build_chip_paragraph if t.TYPE_CHECKING: from docutils import nodes @@ -90,29 +91,23 @@ def _writer_fact_rows(cls: type[Writer[t.Any]]) -> list[ApiFactRow]: ['Python path', 'Supported formats', 'Translator class', 'Config section', 'Transforms'] """ translator = resolve_translator_class(cls) - translator_path = ( - f"{translator.__module__}.{translator.__name__}" + translator_body = ( + linked_paragraph(f"{translator.__module__}.{translator.__qualname__}") if translator is not None - else "—" + else _literal_paragraph("—") ) return [ ApiFactRow( "Python path", - _literal_paragraph(f"{cls.__module__}.{cls.__name__}"), + linked_paragraph(f"{cls.__module__}.{cls.__name__}"), ), - ApiFactRow( - "Supported formats", - _literal_paragraph(", ".join(cls.supported) or "—"), - ), - ApiFactRow("Translator class", _literal_paragraph(translator_path)), + ApiFactRow("Supported formats", build_chip_paragraph(list(cls.supported))), + ApiFactRow("Translator class", translator_body), ApiFactRow( "Config section", _literal_paragraph(cls.config_section or "—"), ), - ApiFactRow( - "Transforms", - _literal_paragraph(", ".join(safe_transform_names(cls)) or "—"), - ), + ApiFactRow("Transforms", build_chip_paragraph(transform_chip_nodes(cls))), ] diff --git a/tests/ext/autodoc_docutils/test_components.py b/tests/ext/autodoc_docutils/test_components.py index 51814549..7888d98f 100644 --- a/tests/ext/autodoc_docutils/test_components.py +++ b/tests/ext/autodoc_docutils/test_components.py @@ -217,6 +217,60 @@ def test_import_component_rejects_non_class() -> None: import_component("docutils.transforms.misc.__doc__") +# --------------------------------------------------------------------------- +# Linked facts +# --------------------------------------------------------------------------- + + +def test_python_path_fact_is_linked() -> None: + """The Python path fact wraps the dotted path in a py-obj xref.""" + rows = _transform_fact_rows(TransformInfo(cls=_DemoTransform)) + xref = next(iter(rows[0].body.findall(addnodes.pending_xref))) + assert xref["refdomain"] == "py" + assert xref["reftarget"].endswith("._DemoTransform") + assert xref["refwarn"] is False + + +def test_reader_transforms_fact_links_qualified_targets() -> None: + """Transform chips display bare names but target qualified paths.""" + from docutils.readers.standalone import Reader + + rows = _reader_fact_rows(Reader) + transforms_row = next(row for row in rows if row.label == "Transforms") + xrefs = list(transforms_row.body.findall(addnodes.pending_xref)) + assert xrefs + targets = {xref["reftarget"] for xref in xrefs} + assert "docutils.transforms.misc.Transitions" in targets + displays = {xref.astext() for xref in xrefs} + assert "Transitions" in displays + + +def test_translator_overrides_fact_links_methods() -> None: + """Override chips target the fully-qualified method paths.""" + rows = _translator_fact_rows(TranslatorInfo(cls=_DemoVisitor)) + overrides_row = next(row for row in rows if row.label == "Overrides") + prefix = f"{_DemoVisitor.__module__}.{_DemoVisitor.__qualname__}" + targets = [ + xref["reftarget"] for xref in overrides_row.body.findall(addnodes.pending_xref) + ] + assert targets == [ + f"{prefix}.depart_paragraph", + f"{prefix}.visit_paragraph", + ] + + +def test_supported_formats_fact_renders_chips() -> None: + """List-valued facts render one literal chip per value.""" + from docutils.writers import html5_polyglot + + rows = _writer_fact_rows(html5_polyglot.Writer) + formats_row = next(row for row in rows if row.label == "Supported formats") + literal_count = sum( + isinstance(child, nodes.literal) for child in formats_row.body.children + ) + assert literal_count == 3 + + # --------------------------------------------------------------------------- # Transforms # --------------------------------------------------------------------------- diff --git a/tests/ext/autodoc_docutils/test_domain_xref_integration.py b/tests/ext/autodoc_docutils/test_domain_xref_integration.py index 32437a0a..63d112aa 100644 --- a/tests/ext/autodoc_docutils/test_domain_xref_integration.py +++ b/tests/ext/autodoc_docutils/test_domain_xref_integration.py @@ -47,6 +47,7 @@ def apply(self): sys.path.insert(0, r"__SCENARIO_SRCDIR__") extensions = [ + "sphinx.ext.autodoc", "sphinx_autodoc_docutils", ] """ @@ -62,6 +63,12 @@ def apply(self): usage .. autotransform:: demo_xref_components.DemoXrefTransform + + Full API + -------- + + .. automodule:: demo_xref_components + :members: """ ) @@ -162,3 +169,17 @@ def test_domain_data_populated_after_build( """The documented transform lands in the docutils domain data.""" domain_data = docutils_xref_result.app.env.domaindata["docutils"] assert "demo_xref_components.DemoXrefTransform" in domain_data["transform"] + + +@pytest.mark.integration +def test_python_path_fact_resolves_to_automodule_target( + docutils_xref_result: SharedSphinxResult, +) -> None: + """The Python path fact links to the class's py-domain target. + + The scenario documents the demo module via automodule, so the + fact's py-obj cross-reference resolves to the autodoc anchor on + the same page. + """ + index_html = read_output(docutils_xref_result, "index.html") + assert 'href="#demo_xref_components.DemoXrefTransform"' in index_html From 8b174c1c4b46b828ab081f978861331c6b7e2081 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 12:42:09 -0500 Subject: [PATCH 18/33] gp-sphinx(autodoc-sphinx[facts]): Link and un-clump builder/domain facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Same fact-presentation defects as the docutils package — image types and domain surfaces (object types, roles, directives, indices) clumped into single literals, and Python paths plus default translators dead text despite frequently having automodule targets. what: - Python path and Default translator render as linked literals via the shared helpers; the translator chip targets the qualified class path - Supported image types and the domain surface facts render as individual chips - Add linked_paragraph() to the package's component helpers and structure assertions for the linked/chipped facts --- .../sphinx_autodoc_sphinx/_builders_doc.py | 19 +++++----- .../src/sphinx_autodoc_sphinx/_components.py | 15 ++++++++ .../src/sphinx_autodoc_sphinx/_domains_doc.py | 20 +++++------ tests/ext/autodoc_sphinx/test_components.py | 36 +++++++++++++++++++ 4 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py index 6178e776..2cee2714 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py @@ -15,12 +15,13 @@ component_classes, component_summary, import_component, + linked_paragraph, render_component_nodes, replay_setup, ) from sphinx_autodoc_sphinx._directives import _literal_paragraph from sphinx_autodoc_sphinx.domain import BUILDER -from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout import ApiFactRow, build_chip_paragraph if t.TYPE_CHECKING: from docutils import nodes @@ -155,21 +156,23 @@ def _builder_fact_rows(info: BuilderInfo) -> list[ApiFactRow]: 'Epilog'] """ cls = info.cls - image_types = ", ".join(cls.supported_image_types) or "—" # default_translator_class only exists on translator-driven # builders (StandaloneHTMLBuilder and friends), not the base. translator = getattr(cls, "default_translator_class", None) - translator_path = ( - f"{translator.__module__}.{translator.__name__}" + translator_body = ( + linked_paragraph(f"{translator.__module__}.{translator.__qualname__}") if inspect.isclass(translator) - else "—" + else _literal_paragraph("—") ) return [ - ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow("Python path", linked_paragraph(info.qualified_name)), ApiFactRow("Builder name", _literal_paragraph(info.builder_name or "—")), ApiFactRow("Output format", _literal_paragraph(str(cls.format) or "—")), - ApiFactRow("Supported image types", _literal_paragraph(image_types)), - ApiFactRow("Default translator", _literal_paragraph(translator_path)), + ApiFactRow( + "Supported image types", + build_chip_paragraph(list(cls.supported_image_types)), + ), + ApiFactRow("Default translator", translator_body), ApiFactRow("Parallel-safe", _literal_paragraph(str(cls.allow_parallel))), ApiFactRow("Epilog", _literal_paragraph(str(cls.epilog) or "—")), ] diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py index 3dee9193..192f53ba 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py @@ -21,6 +21,8 @@ from sphinx_autodoc_sphinx._directives import RecorderApp from sphinx_ux_autodoc_layout import ( build_api_facts_section, + build_chip_paragraph, + build_linked_literal, inject_signature_slots, iter_desc_nodes, parse_generated_markup, @@ -155,6 +157,19 @@ def component_summary(value: object) -> str: return "" +def linked_paragraph(target: str, display: str | None = None) -> nodes.paragraph: + """Return a paragraph holding one linked literal chip. + + Examples + -------- + >>> linked_paragraph("pkg.mod.Cls").astext() + 'pkg.mod.Cls' + >>> linked_paragraph("pkg.mod.Cls", "Cls").astext() + 'Cls' + """ + return build_chip_paragraph([build_linked_literal(target, display)]) + + def import_component(path: str) -> type: """Import one component class from a dotted ``module.ClassName`` path. diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_domains_doc.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_domains_doc.py index 1e030351..fe918a09 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_domains_doc.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_domains_doc.py @@ -15,12 +15,13 @@ component_classes, component_summary, import_component, + linked_paragraph, render_component_nodes, replay_setup, ) from sphinx_autodoc_sphinx._directives import _literal_paragraph from sphinx_autodoc_sphinx.domain import DOMAIN -from sphinx_ux_autodoc_layout import ApiFactRow +from sphinx_ux_autodoc_layout import ApiFactRow, build_chip_paragraph if t.TYPE_CHECKING: from docutils import nodes @@ -158,19 +159,18 @@ def _domain_fact_rows(info: DomainInfo) -> list[ApiFactRow]: 'Directives', 'Indices'] """ cls = info.cls - object_types = ", ".join(sorted(cls.object_types)) or "—" - roles = ", ".join(sorted(cls.roles)) or "—" - domain_directives = ", ".join(sorted(cls.directives)) or "—" - indices = ", ".join(index.name for index in cls.indices) or "—" return [ - ApiFactRow("Python path", _literal_paragraph(info.qualified_name)), + ApiFactRow("Python path", linked_paragraph(info.qualified_name)), ApiFactRow("Domain name", _literal_paragraph(info.domain_name or "—")), # str() unwraps the lazy gettext proxy Sphinx domains use. ApiFactRow("Label", _literal_paragraph(str(cls.label) or "—")), - ApiFactRow("Object types", _literal_paragraph(object_types)), - ApiFactRow("Roles", _literal_paragraph(roles)), - ApiFactRow("Directives", _literal_paragraph(domain_directives)), - ApiFactRow("Indices", _literal_paragraph(indices)), + ApiFactRow("Object types", build_chip_paragraph(sorted(cls.object_types))), + ApiFactRow("Roles", build_chip_paragraph(sorted(cls.roles))), + ApiFactRow("Directives", build_chip_paragraph(sorted(cls.directives))), + ApiFactRow( + "Indices", + build_chip_paragraph([index.name for index in cls.indices]), + ), ] diff --git a/tests/ext/autodoc_sphinx/test_components.py b/tests/ext/autodoc_sphinx/test_components.py index ef536dce..69d77a26 100644 --- a/tests/ext/autodoc_sphinx/test_components.py +++ b/tests/ext/autodoc_sphinx/test_components.py @@ -374,3 +374,39 @@ class _BareDomain(BaseDomain): assert by_label["Roles"] == "—" assert by_label["Directives"] == "—" assert by_label["Indices"] == "—" + + +# --------------------------------------------------------------------------- +# Linked facts +# --------------------------------------------------------------------------- + + +def test_builder_python_path_fact_is_linked() -> None: + """The Python path fact wraps the dotted path in a py-obj xref.""" + rows = _builder_fact_rows(BuilderInfo(cls=_DemoBuilder)) + xref = next(iter(rows[0].body.findall(addnodes.pending_xref))) + assert xref["refdomain"] == "py" + assert xref["reftarget"].endswith("._DemoBuilder") + assert xref["refwarn"] is False + + +def test_builder_default_translator_fact_is_linked() -> None: + """A resolved default translator links to its qualified path.""" + from sphinx.builders.html import StandaloneHTMLBuilder + + rows = _builder_fact_rows(BuilderInfo(cls=StandaloneHTMLBuilder)) + translator_row = next(row for row in rows if row.label == "Default translator") + xref = next(iter(translator_row.body.findall(addnodes.pending_xref))) + assert xref["reftarget"].endswith("HTML5Translator") + + +def test_domain_surface_facts_render_chips() -> None: + """Object types and roles render one literal chip per value.""" + from sphinx_autodoc_argparse.domain import ArgparseDomain + + rows = _domain_fact_rows(DomainInfo(cls=ArgparseDomain)) + object_types_row = next(row for row in rows if row.label == "Object types") + literal_count = sum( + isinstance(child, nodes.literal) for child in object_types_row.body.children + ) + assert literal_count == 4 From b82ac08d6958cb602d0dad9f39292b06adaabb74 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 12:43:20 -0500 Subject: [PATCH 19/33] docs(CHANGES) Linked chips in component autodoc facts why: The unreleased component-autodoc deliverable now also covers fact presentation: per-value chips and py-domain cross-links. what: - Extend the deliverable prose with the chip rendering and graceful-degradation link behavior --- CHANGES | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 75772343..c31db5d1 100644 --- a/CHANGES +++ b/CHANGES @@ -30,7 +30,11 @@ family with the same single + bulk-by-module pattern that per-type kind badge, and registry-aware facts — a transform's `default_priority` and registration phase, a writer's resolved translator class, a node's element categories and per-builder -visit/depart handlers, a translator's own handler overrides. Bulk +visit/depart handlers, a translator's own handler overrides. +List-valued facts render as individual chips, and names with +documented py-domain targets — Python paths, translator classes, +transform sets, handler overrides — become cross-reference links, +degrading to plain chips when no target exists. Bulk forms replay a package's `setup()` so Sphinx-side registrations (`add_transform`, `add_node`, `add_source_parser`, `set_translator`) surface with their real metadata, and fall back to module scans for From 7ec5f21182925f28df5d6e0f5ee15c03b5f6478a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 12:53:51 -0500 Subject: [PATCH 20/33] gp-sphinx(autodoc-docutils[facts]): Link directive and role Python paths why: The linked-facts pass covered the component modules but missed the original autodirective/autorole fact builders, so role callables and directive classes with real py-domain targets (consumers automodule the same objects) still rendered dead text. what: - Wrap the directive and role Python path facts in the shared linked-literal helper; py:function role targets and py:class directive targets now resolve, degrading silently elsewhere --- .../sphinx_autodoc_docutils/_directives.py | 14 ++++++-- tests/ext/autodoc_docutils/test_doctree.py | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index a556b081..4a3b1238 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -19,6 +19,8 @@ ApiFactRow, build_api_facts_section, build_api_table_section, + build_chip_paragraph, + build_linked_literal, inject_signature_slots, iter_desc_nodes, parse_generated_markup, @@ -380,7 +382,10 @@ def _directive_fact_rows( ) -> list[ApiFactRow]: """Return shared fact rows for one autodocumented directive.""" return [ - ApiFactRow("Python path", _literal_paragraph(path)), + ApiFactRow( + "Python path", + build_chip_paragraph([build_linked_literal(path)]), + ), ApiFactRow( "Required arguments", _literal_paragraph(str(directive_cls.required_arguments)), @@ -399,7 +404,12 @@ def _directive_fact_rows( def _role_fact_rows(path: str, role_fn: object) -> list[ApiFactRow]: """Return shared fact rows for one autodocumented role.""" - rows = [ApiFactRow("Python path", _literal_paragraph(path))] + rows = [ + ApiFactRow( + "Python path", + build_chip_paragraph([build_linked_literal(path)]), + ), + ] content_value = getattr(role_fn, "content", None) if content_value is not None: rows.append( diff --git a/tests/ext/autodoc_docutils/test_doctree.py b/tests/ext/autodoc_docutils/test_doctree.py index 1073de48..0aa574fa 100644 --- a/tests/ext/autodoc_docutils/test_doctree.py +++ b/tests/ext/autodoc_docutils/test_doctree.py @@ -283,6 +283,38 @@ def _bare_role( assert "Accepts role content" not in labels +def test_normalize_directive_python_path_is_linked() -> None: + """The directive Python path fact wraps the path in a py-obj xref.""" + desc = _make_directive_desc(with_option=False) + content = t.cast(addnodes.desc_content, desc.children[-1]) + + _normalize_directive_nodes( + [desc], + path="my_mod.DemoDirective", + directive_cls=_DemoDirective, + ) + + facts = _api_facts_child(content) + assert facts is not None + xref = next(iter(facts.findall(addnodes.pending_xref))) + assert xref["refdomain"] == "py" + assert xref["reftarget"] == "my_mod.DemoDirective" + assert xref["refwarn"] is False + + +def test_normalize_role_python_path_is_linked() -> None: + """The role Python path fact wraps the path in a py-obj xref.""" + desc = _make_role_desc() + content = t.cast(addnodes.desc_content, desc.children[-1]) + + _normalize_role_nodes([desc], path="demo.demo_badge_role", role_fn=_demo_role) + + facts = _api_facts_child(content) + assert facts is not None + xref = next(iter(facts.findall(addnodes.pending_xref))) + assert xref["reftarget"] == "demo.demo_badge_role" + + def test_normalize_role_skips_non_role_descs() -> None: """Desc nodes with non-role objtypes are left untouched.""" directive_desc = _make_directive_desc(with_option=False) From 6231f31eee46faa33c2f28225aade29c87732203 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 12:55:04 -0500 Subject: [PATCH 21/33] gp-sphinx(autodoc-sphinx[facts]): Link confval Registered by why: The Registered by fact names the extension's setup() callable as dead text; sites that document extension surfaces in the py domain (gp-sphinx's own docs do) have a real target for it. what: - Wrap the Registered by value in the shared linked-literal helper, targeting module.setup with the call-syntax display; degrades to the existing literal where setup() is undocumented --- .../src/sphinx_autodoc_sphinx/_directives.py | 14 +++++++++++++- tests/ext/autodoc_sphinx/test_components.py | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py index 4e8a51aa..d2b9c57c 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -29,6 +29,8 @@ from sphinx_ux_autodoc_layout import ( ApiFactRow, build_api_facts_section, + build_chip_paragraph, + build_linked_literal, inject_signature_slots, iter_desc_nodes, parse_generated_markup, @@ -397,7 +399,17 @@ def _config_fact_rows(value: SphinxConfigValue) -> list[ApiFactRow]: ), ), ApiFactRow("Default", default_body), - ApiFactRow("Registered by", _literal_paragraph(f"{value.module_name}.setup()")), + ApiFactRow( + "Registered by", + build_chip_paragraph( + [ + build_linked_literal( + f"{value.module_name}.setup", + f"{value.module_name}.setup()", + ), + ], + ), + ), ] diff --git a/tests/ext/autodoc_sphinx/test_components.py b/tests/ext/autodoc_sphinx/test_components.py index 69d77a26..11d4f59f 100644 --- a/tests/ext/autodoc_sphinx/test_components.py +++ b/tests/ext/autodoc_sphinx/test_components.py @@ -410,3 +410,18 @@ def test_domain_surface_facts_render_chips() -> None: isinstance(child, nodes.literal) for child in object_types_row.body.children ) assert literal_count == 4 + + +def test_config_registered_by_fact_is_linked() -> None: + """The Registered by fact targets the extension's setup function.""" + from sphinx_autodoc_sphinx._directives import ( + SphinxConfigValue, + _config_fact_rows, + ) + + value = SphinxConfigValue("demo_ext", "demo_option", True, "html", (bool,)) + rows = _config_fact_rows(value) + registered_row = next(row for row in rows if row.label == "Registered by") + xref = next(iter(registered_row.body.findall(addnodes.pending_xref))) + assert xref["reftarget"] == "demo_ext.setup" + assert xref.astext() == "demo_ext.setup()" From aee2601f82babe12a810c8b41d6f231aa521105a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:08:19 -0500 Subject: [PATCH 22/33] gp-sphinx(autodoc-sphinx[facts]): Link confval Type annotations why: The Type fact rendered names like list, dict, and bool as dead literals even though Python builtins carry py:class targets in the python intersphinx inventory every consumer site already maps. what: - Route the Type fact through the shared annotation display pipeline (build_annotation_display_paragraph) with the directive's environment, so type names become pending_xrefs that resolve locally or via intersphinx and degrade silently otherwise - Keep bare None and empty type text as plain literals: the display policy would collapse a lone None to the enum marker, and the workspace policy never links None - _config_fact_rows grows an optional env parameter; without it the Type fact renders exactly as before --- .../src/sphinx_autodoc_sphinx/_directives.py | 44 +++++++++++++------ .../test_autodoc_sphinx_integration.py | 20 +++++++++ tests/ext/autodoc_sphinx/test_components.py | 14 ++++++ 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py index d2b9c57c..6c84675f 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -25,7 +25,10 @@ from sphinx.util.docutils import SphinxDirective from sphinx_autodoc_sphinx._badges import build_config_badge_group -from sphinx_autodoc_typehints_gp import normalize_type_collection_text +from sphinx_autodoc_typehints_gp import ( + build_annotation_display_paragraph, + normalize_type_collection_text, +) from sphinx_ux_autodoc_layout import ( ApiFactRow, build_api_facts_section, @@ -37,6 +40,7 @@ ) if t.TYPE_CHECKING: + from sphinx.environment import BuildEnvironment from sphinx.util.typing import OptionSpec _COMPLEX_REPR_THRESHOLD = 60 @@ -381,23 +385,35 @@ def _inject_config_badges( ) -def _config_fact_rows(value: SphinxConfigValue) -> list[ApiFactRow]: - """Return shared fact rows for one config value.""" +def _config_fact_rows( + value: SphinxConfigValue, + *, + env: BuildEnvironment | None = None, +) -> list[ApiFactRow]: + """Return shared fact rows for one config value. + + With *env*, the Type fact renders through the shared annotation + pipeline, so type names with py-domain targets — builtins via the + python intersphinx inventory included — become cross-reference + links. Without it, the Type fact stays a plain literal. + """ default_body: nodes.Node if _is_complex_default(value.default): default_body = _make_default_block(value.default) else: default_body = _literal_paragraph(repr(value.default)) + type_text = normalize_type_collection_text(value.types, default=value.default) + type_body: nodes.Node + if type_text in {"", "None"}: + # The shared display policy treats a bare ``None`` as a + # literal-enum member (collapsing it to the ``enum`` marker), + # and the workspace policy never links ``None`` anyway — keep + # the plain literal for both. + type_body = _literal_paragraph(type_text) + else: + type_body = build_annotation_display_paragraph(type_text, env) return [ - ApiFactRow( - "Type", - _literal_paragraph( - normalize_type_collection_text( - value.types, - default=value.default, - ) - ), - ), + ApiFactRow("Type", type_body), ApiFactRow("Default", default_body), ApiFactRow( "Registered by", @@ -426,7 +442,9 @@ def _render_config_value_nodes( ) _inject_config_badges(value_nodes, value) for desc_content in _iter_desc_content(value_nodes): - desc_content += build_api_facts_section(_config_fact_rows(value)) + desc_content += build_api_facts_section( + _config_fact_rows(value, env=directive.env), + ) return value_nodes diff --git a/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py index f38a9808..5c94b34f 100644 --- a/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py +++ b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py @@ -187,3 +187,23 @@ def test_autodoc_sphinx_domain_entries( assert ">recipe<" in html # Literal body text splits into per-word chunks. assert ">recipes<" in html + + +@pytest.mark.integration +def test_config_type_fact_links_with_env( + autodoc_sphinx_html_result: SharedSphinxResult, +) -> None: + """With a live environment the Type fact carries py-domain xrefs.""" + from sphinx import addnodes + + from sphinx_autodoc_sphinx._directives import ( + SphinxConfigValue, + _config_fact_rows, + ) + + value = SphinxConfigValue("demo_ext", "demo_option", True, "html", (bool,)) + rows = _config_fact_rows(value, env=autodoc_sphinx_html_result.app.env) + type_row = next(row for row in rows if row.label == "Type") + xref = next(iter(type_row.body.findall(addnodes.pending_xref))) + assert xref["reftarget"] == "bool" + assert type_row.body.astext() == "bool" diff --git a/tests/ext/autodoc_sphinx/test_components.py b/tests/ext/autodoc_sphinx/test_components.py index 11d4f59f..558328d7 100644 --- a/tests/ext/autodoc_sphinx/test_components.py +++ b/tests/ext/autodoc_sphinx/test_components.py @@ -425,3 +425,17 @@ def test_config_registered_by_fact_is_linked() -> None: xref = next(iter(registered_row.body.findall(addnodes.pending_xref))) assert xref["reftarget"] == "demo_ext.setup" assert xref.astext() == "demo_ext.setup()" + + +def test_config_type_fact_plain_without_env() -> None: + """Without an environment the Type fact stays a literal rendering.""" + from sphinx_autodoc_sphinx._directives import ( + SphinxConfigValue, + _config_fact_rows, + ) + + value = SphinxConfigValue("demo_ext", "demo_option", True, "html", (bool,)) + rows = _config_fact_rows(value) + type_row = next(row for row in rows if row.label == "Type") + assert type_row.body.astext() == "bool" + assert not list(type_row.body.findall(addnodes.pending_xref)) From 43215f8c3c8d5db21e6a6205fbf2d13e85240a52 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:10:54 -0500 Subject: [PATCH 23/33] docs(packages[reference]): Document extension setup entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Confval "Registered by" facts cross-reference .setup, but only sphinx-ux-badges documented its setup function — every other package's link degraded to dead text on our own site. what: - Add an "Extension entry point" autofunction section to the ten reference pages missing one, matching the sphinx-ux-badges precedent - Add a minimal reference page for sphinx-autodoc-pytest-fixtures (directives + entry point; its confvals stay on the how-to page to avoid duplicate targets) --- .../sphinx-autodoc-api-style/reference.md | 6 ++++++ .../sphinx-autodoc-argparse/reference.md | 6 ++++++ .../sphinx-autodoc-docutils/reference.md | 6 ++++++ .../sphinx-autodoc-fastmcp/reference.md | 6 ++++++ .../reference.md | 19 +++++++++++++++++++ .../sphinx-autodoc-sphinx/reference.md | 6 ++++++ docs/packages/sphinx-fonts/reference.md | 6 ++++++ docs/packages/sphinx-gp-llms/reference.md | 6 ++++++ .../packages/sphinx-gp-opengraph/reference.md | 6 ++++++ docs/packages/sphinx-gp-sitemap/reference.md | 6 ++++++ .../sphinx-ux-autodoc-layout/reference.md | 6 ++++++ 11 files changed, 79 insertions(+) create mode 100644 docs/packages/sphinx-autodoc-pytest-fixtures/reference.md diff --git a/docs/packages/sphinx-autodoc-api-style/reference.md b/docs/packages/sphinx-autodoc-api-style/reference.md index 194391ba..b66ec6a8 100644 --- a/docs/packages/sphinx-autodoc-api-style/reference.md +++ b/docs/packages/sphinx-autodoc-api-style/reference.md @@ -27,3 +27,9 @@ This extension uses: | `deprecated` | `SAB.STATE_DEPRECATED` | `gp-sphinx-badge--state-deprecated` | See {doc}`/packages/sphinx-ux-badges/index` for the full shared palette. + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_api_style.setup +``` diff --git a/docs/packages/sphinx-autodoc-argparse/reference.md b/docs/packages/sphinx-autodoc-argparse/reference.md index 941b4b56..32a6e98a 100644 --- a/docs/packages/sphinx-autodoc-argparse/reference.md +++ b/docs/packages/sphinx-autodoc-argparse/reference.md @@ -52,3 +52,9 @@ for program-scoped clarity. ```{eval-rst} .. autoconfigvalues:: sphinx_autodoc_argparse.exemplar ``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_argparse.setup +``` diff --git a/docs/packages/sphinx-autodoc-docutils/reference.md b/docs/packages/sphinx-autodoc-docutils/reference.md index 09c3a093..0fe1b999 100644 --- a/docs/packages/sphinx-autodoc-docutils/reference.md +++ b/docs/packages/sphinx-autodoc-docutils/reference.md @@ -35,3 +35,9 @@ warn at build time. The domain also ships a grouped components index: {ref}`docutils-componentindex`. + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_docutils.setup +``` diff --git a/docs/packages/sphinx-autodoc-fastmcp/reference.md b/docs/packages/sphinx-autodoc-fastmcp/reference.md index 9254b68c..270d0b77 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/reference.md +++ b/docs/packages/sphinx-autodoc-fastmcp/reference.md @@ -22,3 +22,9 @@ via `sphinx-autodoc-docutils`. .. autoroles:: sphinx_autodoc_fastmcp ``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_fastmcp.setup +``` diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures/reference.md b/docs/packages/sphinx-autodoc-pytest-fixtures/reference.md new file mode 100644 index 00000000..404b1b5c --- /dev/null +++ b/docs/packages/sphinx-autodoc-pytest-fixtures/reference.md @@ -0,0 +1,19 @@ +(sphinx-autodoc-pytest-fixtures-reference)= + +# API Reference + +## Directive reference + +Generated from `app.add_directive()` registrations in +[`sphinx_autodoc_pytest_fixtures/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py) +via `sphinx-autodoc-docutils`. + +```{eval-rst} +.. autodirectives:: sphinx_autodoc_pytest_fixtures +``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_pytest_fixtures.setup +``` diff --git a/docs/packages/sphinx-autodoc-sphinx/reference.md b/docs/packages/sphinx-autodoc-sphinx/reference.md index 077bdcfe..4ddbbe98 100644 --- a/docs/packages/sphinx-autodoc-sphinx/reference.md +++ b/docs/packages/sphinx-autodoc-sphinx/reference.md @@ -32,3 +32,9 @@ warn at build time. The domain also ships a grouped components index: {ref}`sphinxext-componentindex`. + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_sphinx.setup +``` diff --git a/docs/packages/sphinx-fonts/reference.md b/docs/packages/sphinx-fonts/reference.md index ef5677ef..86c84f4b 100644 --- a/docs/packages/sphinx-fonts/reference.md +++ b/docs/packages/sphinx-fonts/reference.md @@ -7,3 +7,9 @@ ```{eval-rst} .. autoconfigvalues:: sphinx_fonts ``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_fonts.setup +``` diff --git a/docs/packages/sphinx-gp-llms/reference.md b/docs/packages/sphinx-gp-llms/reference.md index fcc2e4f0..7cb314a0 100644 --- a/docs/packages/sphinx-gp-llms/reference.md +++ b/docs/packages/sphinx-gp-llms/reference.md @@ -11,3 +11,9 @@ Generated from `app.add_config_value()` registrations in .. autoconfigvalues:: sphinx_gp_llms :exclude: site_url ``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_gp_llms.setup +``` diff --git a/docs/packages/sphinx-gp-opengraph/reference.md b/docs/packages/sphinx-gp-opengraph/reference.md index e7b1d139..329e1a23 100644 --- a/docs/packages/sphinx-gp-opengraph/reference.md +++ b/docs/packages/sphinx-gp-opengraph/reference.md @@ -10,3 +10,9 @@ Generated from `app.add_config_value()` registrations in ```{eval-rst} .. autoconfigvalues:: sphinx_gp_opengraph ``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_gp_opengraph.setup +``` diff --git a/docs/packages/sphinx-gp-sitemap/reference.md b/docs/packages/sphinx-gp-sitemap/reference.md index be2dbd3a..864328fe 100644 --- a/docs/packages/sphinx-gp-sitemap/reference.md +++ b/docs/packages/sphinx-gp-sitemap/reference.md @@ -10,3 +10,9 @@ Generated from `app.add_config_value()` registrations in ```{eval-rst} .. autoconfigvalues:: sphinx_gp_sitemap ``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_gp_sitemap.setup +``` diff --git a/docs/packages/sphinx-ux-autodoc-layout/reference.md b/docs/packages/sphinx-ux-autodoc-layout/reference.md index 31cf4946..691ccc4c 100644 --- a/docs/packages/sphinx-ux-autodoc-layout/reference.md +++ b/docs/packages/sphinx-ux-autodoc-layout/reference.md @@ -40,3 +40,9 @@ ```{package-reference} sphinx-ux-autodoc-layout ``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_ux_autodoc_layout.setup +``` From 7fdd466558bfd541903179873c19a147b7bb47b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:12:03 -0500 Subject: [PATCH 24/33] docs(CHANGES) Config types and setup entry points in linked facts why: The linked-facts sentence in the unreleased deliverable now also covers config value types and their registering entry points. what: - Extend the deliverable's linked-targets list --- CHANGES | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index c31db5d1..fceaf00c 100644 --- a/CHANGES +++ b/CHANGES @@ -33,7 +33,8 @@ translator class, a node's element categories and per-builder visit/depart handlers, a translator's own handler overrides. List-valued facts render as individual chips, and names with documented py-domain targets — Python paths, translator classes, -transform sets, handler overrides — become cross-reference links, +transform sets, handler overrides, config value types and their +registering `setup()` entry points — become cross-reference links, degrading to plain chips when no target exists. Bulk forms replay a package's `setup()` so Sphinx-side registrations (`add_transform`, `add_node`, `add_source_parser`, `set_translator`) From a6bde6990b7adc19e5950ac4a5bbf7ab82070405 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:33:37 -0500 Subject: [PATCH 25/33] gp-sphinx(autodoc-sphinx[facts]): Pygments-highlight container defaults why: Small dict and list defaults rendered as inline literal blobs; structured values read better as highlighted Python, and the Pygments literal_block path already existed for long reprs. what: - _is_complex_default treats any non-empty container as complex, so dict/list/tuple/set defaults render through the existing Pygments-highlighted block regardless of repr length; empty containers and scalars stay inline --- .../src/sphinx_autodoc_sphinx/_directives.py | 17 +++++++++++++---- tests/ext/autodoc_sphinx/test_directives.py | 3 +++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py index 6c84675f..f800cea6 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -169,11 +169,12 @@ def _literal_paragraph(text: str) -> nodes.paragraph: def _is_complex_default(value: object) -> bool: # object: only calls repr() - """Return True when repr of value exceeds the inline display threshold. + """Return True when a default should render as a highlighted block. - Values whose repr is longer than :data:`_COMPLEX_REPR_THRESHOLD` chars - are rendered as a Pygments-highlighted ``literal_block`` node rather than - as an inline ``:default:`` field literal. + Non-empty containers always render as a Pygments-highlighted + ``literal_block`` — dict/list defaults read as structured Python, + not as an inline token. Scalars stay inline unless their repr + exceeds :data:`_COMPLEX_REPR_THRESHOLD` chars. Examples -------- @@ -181,9 +182,17 @@ def _is_complex_default(value: object) -> bool: # object: only calls repr() False >>> _is_complex_default("warning") False + >>> _is_complex_default({}) + False + >>> _is_complex_default({"light": "mint", "dark": "teal"}) + True + >>> _is_complex_default(["a"]) + True >>> _is_complex_default(frozenset(range(15))) True """ + if isinstance(value, (dict, list, tuple, set, frozenset)) and value: + return True return len(repr(value)) > _COMPLEX_REPR_THRESHOLD diff --git a/tests/ext/autodoc_sphinx/test_directives.py b/tests/ext/autodoc_sphinx/test_directives.py index 3b531eba..254a9d89 100644 --- a/tests/ext/autodoc_sphinx/test_directives.py +++ b/tests/ext/autodoc_sphinx/test_directives.py @@ -63,6 +63,9 @@ class IsComplexCase(t.NamedTuple): IsComplexCase(True, False, "bool_simple"), IsComplexCase("warning", False, "short_string"), IsComplexCase({}, False, "empty_dict"), + IsComplexCase((), False, "empty_tuple"), + IsComplexCase({"light": "mint", "dark": "teal"}, True, "small_dict"), + IsComplexCase(["a"], True, "small_list"), IsComplexCase({"k" * 5: "v" * 60}, True, "long_dict"), IsComplexCase(frozenset(range(15)), True, "large_frozenset"), ], From 29454e86c91a7f2a28a0ad4abaae3fa6f0495213 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:35:52 -0500 Subject: [PATCH 26/33] docs(packages[typehints-gp]): Reference page and linked API prose why: The package's public API had no rendered documentation, so its how-to tables named build_resolved_annotation_paragraph and friends as dead text with nothing to link to. what: - Add a reference page autodocumenting the public surface: the four build_* helpers, render_annotation_nodes, the normalize_* text helpers, classify_annotation_display, AnnotationDisplay, and the setup entry point - Link the how-to tables and prose with {func}/{class} roles; typing.get_type_hints links to the python inventory, and sphinx_stringify_annotation mentions use the canonical sphinx.util.typing.stringify_annotation name (Sphinx publishes no target for it) --- .../sphinx-autodoc-typehints-gp/how-to.md | 22 ++++++------ .../sphinx-autodoc-typehints-gp/reference.md | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 docs/packages/sphinx-autodoc-typehints-gp/reference.md diff --git a/docs/packages/sphinx-autodoc-typehints-gp/how-to.md b/docs/packages/sphinx-autodoc-typehints-gp/how-to.md index d93486d3..7beb032c 100644 --- a/docs/packages/sphinx-autodoc-typehints-gp/how-to.md +++ b/docs/packages/sphinx-autodoc-typehints-gp/how-to.md @@ -23,7 +23,7 @@ and skips its own plain-text duplicates — cooperation, not conflict. ## Features -- Resolves type hints statically without `exec()` or `typing.get_type_hints()`. +- Resolves type hints statically without `exec()` or {func}`typing.get_type_hints`. - Works perfectly with `TYPE_CHECKING` blocks. - No text-level race conditions with Napoleon. - Exposes reusable helpers for annotation display classification and rendered @@ -44,8 +44,8 @@ Four `build_*` functions span two axes: | | Resolved (`env` available) | Unresolved (annotation text only) | |---|---|---| -| Raw paragraph | `build_resolved_annotation_paragraph` | `build_annotation_paragraph` | -| Display-classified | `build_resolved_annotation_display_paragraph` | `build_annotation_display_paragraph` | +| Raw paragraph | {func}`~sphinx_autodoc_typehints_gp.build_resolved_annotation_paragraph` | {func}`~sphinx_autodoc_typehints_gp.build_annotation_paragraph` | +| Display-classified | {func}`~sphinx_autodoc_typehints_gp.build_resolved_annotation_display_paragraph` | {func}`~sphinx_autodoc_typehints_gp.build_annotation_display_paragraph` | Use `build_resolved_*` inside `doctree-resolved` event handlers where a `BuildEnvironment` is available. Use `build_*` when you have only the @@ -53,7 +53,8 @@ annotation string. ## Annotation display classification -`classify_annotation_display()` returns an `AnnotationDisplay` with structured +{func}`~sphinx_autodoc_typehints_gp.classify_annotation_display` returns an +{class}`~sphinx_autodoc_typehints_gp.AnnotationDisplay` with structured metadata for UI renderers. All values below are verified against the installed package: @@ -68,16 +69,17 @@ package: `is_literal_enum=True` lets rendering code produce individual badge chips for each member rather than a monolithic code string. This decision used to live in each consumer (FastMCP, pytest-fixtures, api-style); now it lives in -`classify_annotation_display()` so no downstream package re-implements enum -detection heuristics. +{func}`~sphinx_autodoc_typehints_gp.classify_annotation_display` so no +downstream package re-implements enum detection heuristics. ## Static resolution | Approach | `TYPE_CHECKING` block safe | Napoleon text-processing race | |---|---|---| -| `typing.get_type_hints()` | No — resolves at import time | Yes — depends on import order | -| `sphinx_stringify_annotation()` | Yes — resolves at Sphinx build time | No — no text processing | +| {func}`typing.get_type_hints` | No — resolves at import time | Yes — depends on import order | +| `sphinx.util.typing.stringify_annotation()` | Yes — resolves at Sphinx build time | No — no text processing | -This extension uses `sphinx_stringify_annotation()` to resolve annotations at -build time, making it safe with `TYPE_CHECKING` blocks and eliminating +This extension uses `sphinx.util.typing.stringify_annotation()` (Sphinx +publishes no cross-reference target for it) to resolve annotations at build +time, making it safe with `TYPE_CHECKING` blocks and eliminating text-processing races with Napoleon. diff --git a/docs/packages/sphinx-autodoc-typehints-gp/reference.md b/docs/packages/sphinx-autodoc-typehints-gp/reference.md new file mode 100644 index 00000000..c84f1a01 --- /dev/null +++ b/docs/packages/sphinx-autodoc-typehints-gp/reference.md @@ -0,0 +1,36 @@ +(sphinx-autodoc-typehints-gp-reference)= + +# API Reference + +## Annotation rendering + +```{eval-rst} +.. autofunction:: sphinx_autodoc_typehints_gp.build_annotation_paragraph + +.. autofunction:: sphinx_autodoc_typehints_gp.build_annotation_display_paragraph + +.. autofunction:: sphinx_autodoc_typehints_gp.build_resolved_annotation_paragraph + +.. autofunction:: sphinx_autodoc_typehints_gp.build_resolved_annotation_display_paragraph + +.. autofunction:: sphinx_autodoc_typehints_gp.render_annotation_nodes +``` + +## Annotation text and classification + +```{eval-rst} +.. autofunction:: sphinx_autodoc_typehints_gp.normalize_annotation_text + +.. autofunction:: sphinx_autodoc_typehints_gp.normalize_type_collection_text + +.. autofunction:: sphinx_autodoc_typehints_gp.classify_annotation_display + +.. autoclass:: sphinx_autodoc_typehints_gp.AnnotationDisplay + :members: +``` + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_typehints_gp.setup +``` From dec5a6f71f5f5870031f61c35267234abd8c7cf8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:38:42 -0500 Subject: [PATCH 27/33] docs(packages[examples]): Demo module reference sections why: Demo modules under docs/_ext were never autodocumented, so every demo Python-path fact (docutils_demo.DemoBadgeDirective, sphinx_config_demo.setup(), ...) degraded to dead text with nothing to link to. what: - Append a "Demo module reference" automodule section to the docutils, sphinx, and fastmcp examples pages, giving the demo objects py-domain anchors the entries' facts now resolve to - pytest-fixtures excluded: its fixture entries already create py-domain descriptions on the same page, so an automodule block emits duplicate-object warnings --- .../sphinx-autodoc-docutils/examples.md | 15 ++++++++++++++ .../sphinx-autodoc-fastmcp/examples.md | 10 ++++++++++ .../examples.md | 1 + .../sphinx-autodoc-sphinx/examples.md | 20 +++++++++++++++++++ tests/test_docs_package_pages.py | 1 + 5 files changed, 47 insertions(+) diff --git a/docs/packages/sphinx-autodoc-docutils/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md index 650da486..b88b4949 100644 --- a/docs/packages/sphinx-autodoc-docutils/examples.md +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -161,3 +161,18 @@ the live `setup()` calls. ``` [Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-docutils) · [PyPI](https://pypi.org/project/sphinx-autodoc-docutils/) + +## Demo module reference + +The demo objects above, as plain Python API — the targets the +entries' `Python path` facts link to: + +```{eval-rst} +.. automodule:: docutils_demo + :members: +``` + +```{eval-rst} +.. automodule:: docutils_demo_components + :members: +``` diff --git a/docs/packages/sphinx-autodoc-fastmcp/examples.md b/docs/packages/sphinx-autodoc-fastmcp/examples.md index 78d5182e..97b3fb7a 100644 --- a/docs/packages/sphinx-autodoc-fastmcp/examples.md +++ b/docs/packages/sphinx-autodoc-fastmcp/examples.md @@ -28,3 +28,13 @@ for a plain inline reference. ```{eval-rst} .. fastmcp-tool-summary:: ``` + +## Demo module reference + +The demo objects above, as plain Python API — the targets the +entries' `Python path` facts link to: + +```{eval-rst} +.. automodule:: fastmcp_demo_tools + :members: +``` diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures/examples.md b/docs/packages/sphinx-autodoc-pytest-fixtures/examples.md index 4d625b2e..b0415b54 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures/examples.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures/examples.md @@ -65,3 +65,4 @@ generated fixture summary and reference. ``` [Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-pytest-fixtures) · [PyPI](https://pypi.org/project/sphinx-autodoc-pytest-fixtures/) + diff --git a/docs/packages/sphinx-autodoc-sphinx/examples.md b/docs/packages/sphinx-autodoc-sphinx/examples.md index 17815106..7ea832c0 100644 --- a/docs/packages/sphinx-autodoc-sphinx/examples.md +++ b/docs/packages/sphinx-autodoc-sphinx/examples.md @@ -65,3 +65,23 @@ The bulk form replays a package's `setup()` — here documenting the Component entries register targets in the `sphinxext` domain, so prose can link to them: {sphinxext:builder}`DemoArchiveBuilder` and {sphinxext:domain}`DemoTopicDomain` resolve to the entries above. + +## Demo module reference + +The demo objects above, as plain Python API — the targets the +entries' `Python path` facts link to: + +```{eval-rst} +.. automodule:: sphinx_config_demo + :members: +``` + +```{eval-rst} +.. automodule:: sphinx_config_single_demo + :members: +``` + +```{eval-rst} +.. automodule:: sphinx_demo_builder + :members: +``` diff --git a/tests/test_docs_package_pages.py b/tests/test_docs_package_pages.py index 13d56352..c4368d6b 100644 --- a/tests/test_docs_package_pages.py +++ b/tests/test_docs_package_pages.py @@ -95,6 +95,7 @@ def _fastmcp_docs_page() -> str: extensions = [ "myst_parser", "sphinx_design", + "sphinx.ext.autodoc", "package_reference", "sphinx_autodoc_fastmcp", ] From 5ee8fee5ad031685b65a611172a11b8ffc9f9fac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:52:02 -0500 Subject: [PATCH 28/33] gp-sphinx(test[collection]): Never collect docs/_build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: pytest's default norecursedirs covers "build" but not Sphinx's "_build", so generated markdown under docs/_build collects as doctest files and breaks the run whenever a build output exists — including mid-session, since the objects-inv compatibility test's live docs build leaks output there. what: - Set norecursedirs explicitly, adding _build and node_modules to the conventional exclusions --- pyproject.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 27d2767d..2b804d2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,6 +207,18 @@ convention = "numpy" [tool.pytest.ini_options] addopts = "-s --tb=short --no-header --showlocals --doctest-modules" doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE" +# pytest's default norecursedirs covers "build" but not Sphinx's +# "_build"; generated markdown under docs/_build otherwise collects as +# doctest files and breaks collection whenever a build output exists +# (tests that build the live docs/ tree can create it mid-session). +norecursedirs = [ + ".*", + "*.egg", + "_build", + "build", + "dist", + "node_modules", +] markers = [ "integration: sphinx integration tests (require full sphinx build)", ] From 9f12e7135079451d6978f7ae860f75f6d1eb5183 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:56:10 -0500 Subject: [PATCH 29/33] gp-sphinx(autodoc-docutils[facts]): Link Registered-via and option validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: A full sweep of every package's autodoc output found two more name-valued facts rendered as dead text: the transform/parser "Registered via app.add_*()" calls and directive option "Validator" converters, all of which name real callables. what: - Registered via facts link to sphinx.application.Sphinx.add_* (the sphinx inventory resolves them where mapped; degrades to the literal call elsewhere) - Directive option validators link to their converter callable — builtins target the python inventory, docutils converters their qualified path — degrading when no inventory is mapped - Rebuild _option_field_list directly from the option_spec (the string round-trip through _option_rows could not carry converter identity) --- .../sphinx_autodoc_docutils/_directives.py | 47 +++++++++++++++---- .../sphinx_autodoc_docutils/_parsers_doc.py | 7 ++- .../_transforms_doc.py | 7 ++- tests/ext/autodoc_docutils/test_components.py | 24 ++++++++++ 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index 4a3b1238..6a6b510b 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -297,20 +297,51 @@ def _literal_paragraph(text: str) -> nodes.paragraph: return paragraph +def _converter_target(converter: object) -> str: + """Return the cross-reference target for an option-spec converter. + + Builtins drop their module prefix so they match the python + inventory keys; everything else uses the qualified dotted path. + Returns an empty string for objects without importable identity. + + Examples + -------- + >>> from docutils.parsers.rst import directives + >>> _converter_target(directives.class_option) + 'docutils.parsers.rst.directives.class_option' + >>> _converter_target(str) + 'str' + >>> _converter_target(object()) + '' + """ + module = getattr(converter, "__module__", "") + name = getattr(converter, "__qualname__", "") + if not module or not name: + return "" + if module == "builtins": + return name + return f"{module}.{name}" + + def _option_field_list(option_spec: OptionSpec | None) -> nodes.field_list | None: """Return a field-list representation of an option spec.""" - rows = _option_rows(option_spec) - if not rows: + if not isinstance(option_spec, dict) or not option_spec: return None field_list = nodes.field_list() - for row in rows: - option_name, converter_name = row.split("|")[1:3] - clean_option_name = option_name.strip().strip("`") - clean_converter_name = converter_name.strip().strip("`") + for option_name, converter in sorted(option_spec.items()): + converter_name = getattr(converter, "__name__", type(converter).__name__) + target = _converter_target(converter) + body: nodes.paragraph + if target: + body = build_chip_paragraph( + [build_linked_literal(target, converter_name)], + ) + else: + body = _literal_paragraph(converter_name) field_list += nodes.field( "", - nodes.field_name("", clean_option_name), - nodes.field_body("", _literal_paragraph(clean_converter_name)), + nodes.field_name("", option_name), + nodes.field_body("", body), ) return field_list diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py index 3e7c0802..94a972e9 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py @@ -173,7 +173,12 @@ def _parser_fact_rows(info: ParserInfo) -> list[ApiFactRow]: rows.append( ApiFactRow( "Registered via", - _literal_paragraph(f"app.{info.registered_via}()"), + # Links to the Sphinx Application API when the sphinx + # inventory is mapped; degrades to the literal call. + linked_paragraph( + f"sphinx.application.Sphinx.{info.registered_via}", + f"app.{info.registered_via}()", + ), ), ) return rows diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py index e148d91b..72cfb872 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py @@ -178,7 +178,12 @@ def _transform_fact_rows(info: TransformInfo) -> list[ApiFactRow]: rows.append( ApiFactRow( "Registered via", - _literal_paragraph(f"app.{info.registered_via}()"), + # Links to the Sphinx Application API when the sphinx + # inventory is mapped; degrades to the literal call. + linked_paragraph( + f"sphinx.application.Sphinx.{info.registered_via}", + f"app.{info.registered_via}()", + ), ), ) return rows diff --git a/tests/ext/autodoc_docutils/test_components.py b/tests/ext/autodoc_docutils/test_components.py index 7888d98f..22b4f088 100644 --- a/tests/ext/autodoc_docutils/test_components.py +++ b/tests/ext/autodoc_docutils/test_components.py @@ -779,3 +779,27 @@ def test_writer_fact_rows_surface_formats_and_translator() -> None: "docutils.writers.html5_polyglot.HTMLTranslator" ) assert by_label["Transforms"] != "" + + +def test_registered_via_fact_links_sphinx_app() -> None: + """The Registered via fact targets the Sphinx Application method.""" + rows = _transform_fact_rows( + TransformInfo(cls=_DemoTransform, registered_via="add_transform"), + ) + row = next(r for r in rows if r.label == "Registered via") + xref = next(iter(row.body.findall(addnodes.pending_xref))) + assert xref["reftarget"] == "sphinx.application.Sphinx.add_transform" + assert xref.astext() == "app.add_transform()" + + +def test_option_field_list_links_converter() -> None: + """Directive option validators link to their converter callable.""" + from docutils.parsers.rst import directives + + from sphinx_autodoc_docutils._directives import _option_field_list + + field_list = _option_field_list({"class": directives.class_option}) + assert field_list is not None + xref = next(iter(field_list.findall(addnodes.pending_xref))) + assert xref["reftarget"] == "docutils.parsers.rst.directives.class_option" + assert xref.astext() == "class_option" From 886229e7af78a0b69eceaca0a04f7d66321b9bac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:59:00 -0500 Subject: [PATCH 30/33] docs(CHANGES) Pygments-highlighted container defaults why: This round's only new user-facing capability beyond the already-documented linked facts is highlighting structured defaults. what: - Note container config-value defaults rendering as Pygments blocks in the unreleased deliverable --- CHANGES | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index fceaf00c..a900844e 100644 --- a/CHANGES +++ b/CHANGES @@ -35,7 +35,9 @@ List-valued facts render as individual chips, and names with documented py-domain targets — Python paths, translator classes, transform sets, handler overrides, config value types and their registering `setup()` entry points — become cross-reference links, -degrading to plain chips when no target exists. Bulk +degrading to plain chips when no target exists. Container config-value +defaults (dicts, lists) render as Pygments-highlighted Python blocks. +Bulk forms replay a package's `setup()` so Sphinx-side registrations (`add_transform`, `add_node`, `add_source_parser`, `set_translator`) surface with their real metadata, and fall back to module scans for From 5f376fbebe8d7cdc38a348ff5bda6992c40a217c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 14:03:45 -0500 Subject: [PATCH 31/33] gp-sphinx(autodoc-docutils[facts]): Link directive-option validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The role-option validator already linked its converter, but the directive-option "Validator:" line — rendered through the generated rst:directive:option markup, a different path — stayed dead text, an inconsistency next to the now-linked role equivalent. what: - Emit the validator as a :py:obj: role with an explicit converter target in the generated markup: builtin converters link to the python inventory, docutils converters to their qualified path, and both render as plain text where no inventory is mapped (the build is not nitpicky) - Build the option list directly from option_spec and drop the now unused _option_rows string-table helper --- .../sphinx_autodoc_docutils/_directives.py | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index 6a6b510b..248d6ac5 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -272,24 +272,6 @@ def _registered_name(name: str) -> str: return name.removesuffix("Directive").lower() -def _option_rows(option_spec: OptionSpec | None) -> list[str]: - """Return table rows describing a directive or role option spec. - - Examples - -------- - >>> rows = _option_rows({"class": str}) - >>> rows[0] - '| `class` | `str` |' - """ - if not isinstance(option_spec, dict) or not option_spec: - return [] - rows = [] - for name, converter in sorted(option_spec.items()): - converter_name = getattr(converter, "__name__", type(converter).__name__) - rows.append(f"| `{name}` | `{converter_name}` |") - return rows - - def _literal_paragraph(text: str) -> nodes.paragraph: """Return a paragraph containing one literal node.""" paragraph = nodes.paragraph() @@ -525,19 +507,31 @@ def _directive_markup( "", f" {_summary(directive_cls) or 'Autodocumented directive class.'}", ] - option_rows = _option_rows(getattr(directive_cls, "option_spec", None)) - if option_rows: + option_spec = getattr(directive_cls, "option_spec", None) + if isinstance(option_spec, dict) and option_spec: lines.extend(["", " Options:", ""]) - for row in option_rows: - option_name, converter_name = row.split("|")[1:3] - clean_option_name = option_name.strip().strip("`") - clean_converter_name = converter_name.strip().strip("`") + for option_name, converter in sorted(option_spec.items()): + converter_name = getattr( + converter, + "__name__", + type(converter).__name__, + ) + target = _converter_target(converter) + # A :py:obj: with an explicit target links to the converter + # when its inventory is mapped and renders as plain text + # otherwise (the build is not nitpicky), matching the + # role-option validator treatment. + validator = ( + f":py:obj:`{converter_name} <{target}>`" + if target + else f"``{converter_name}``" + ) lines.extend( [ - f" .. rst:directive:option:: {clean_option_name}", + f" .. rst:directive:option:: {option_name}", " :no-index:" if no_index else "", "", - f" Validator: ``{clean_converter_name}``.", + f" Validator: {validator}.", "", ] ) From 498b40c97a92faffd5d1aeb635eb870f7b1deb85 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 14:19:06 -0500 Subject: [PATCH 32/33] docs(CHANGES) Tighten component autodoc release notes to product level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The What's new entries carried docstring-grade detail — per-type fact enumerations, setup-replay/add_* discovery mechanics, and rendering internals — that buried the upgrade-time takeaway. what: - Lead with a one-paragraph summary of the broadened component autodoc - Trim the three deliverables to their user-visible surface: the new directives, what they document, and the cross-linking; mechanism detail moves to the autodoc output and the PR --- CHANGES | 63 +++++++++++++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 38 deletions(-) diff --git a/CHANGES b/CHANGES index a900844e..e17bae4f 100644 --- a/CHANGES +++ b/CHANGES @@ -20,50 +20,37 @@ $ uv add gp-sphinx --prerelease allow ### What's new -#### Component autodoc for every docutils extension point - -`sphinx-autodoc-docutils` now documents the full docutils component -family with the same single + bulk-by-module pattern that -`autodirective`/`autodirectives` established: `autotransform(s)`, -`autoreader(s)`, `autoparser(s)`, `autowriter(s)`, `autonode(s)`, and -`autotranslator(s)`. Each entry renders with the shared card layout, a -per-type kind badge, and registry-aware facts — a transform's -`default_priority` and registration phase, a writer's resolved -translator class, a node's element categories and per-builder -visit/depart handlers, a translator's own handler overrides. -List-valued facts render as individual chips, and names with -documented py-domain targets — Python paths, translator classes, -transform sets, handler overrides, config value types and their -registering `setup()` entry points — become cross-reference links, -degrading to plain chips when no target exists. Container config-value -defaults (dicts, lists) render as Pygments-highlighted Python blocks. -Bulk -forms replay a package's `setup()` so Sphinx-side registrations -(`add_transform`, `add_node`, `add_source_parser`, `set_translator`) -surface with their real metadata, and fall back to module scans for -components that are instantiated directly — discovery never breaks the -build when a component needs framework state to construct. See +gp-sphinx 0.0.1a29 extends component autodoc to the whole docutils and +Sphinx extension family. Transforms, readers, parsers, writers, custom +nodes, translators, builders, and domains now get the same polished +reference cards that directives, roles, and config values already had — +and documented components cross-link to the APIs they reference. + +#### Autodoc for docutils components + +`sphinx-autodoc-docutils` now documents transforms, readers, parsers, +writers, custom nodes, and translators. Each type gets a single +directive and a bulk-by-module form (`autotransform` / +`autotransforms`, and siblings), rendering the shared reference card +with a type badge and the component's registry metadata. See {ref}`sphinx-autodoc-docutils-examples` for live demos. (#53) -#### Builder and domain autodoc +#### Autodoc for Sphinx builders and domains -`sphinx-autodoc-sphinx` gains `autobuilder(s)` and `autodomain(s)`, -documenting `sphinx.builders.Builder` and `sphinx.domains.Domain` -subclasses beside the config values it already covers. Builder entries -surface the CLI name, output format, supported image types, default -translator, and parallel-safety; domain entries surface the registered -name, label, object types, roles, directives, and indices. See -{ref}`sphinx-autodoc-sphinx-examples` for live demos. (#53) +`sphinx-autodoc-sphinx` adds `autobuilder` and `autodomain` (with bulk +forms) alongside its config-value support, documenting `Builder` and +`Domain` subclasses with their registered names, output formats, and +extension surfaces. See {ref}`sphinx-autodoc-sphinx-examples` for live +demos. (#53) #### Cross-reference roles for documented components -Two new Sphinx domains make every component entry linkable: the -`docutils` domain (`` {docutils:transform}`SanitizeTransform` `` and -siblings for readers, parsers, writers, nodes, and translators) and -the `sphinxext` domain (`` {sphinxext:builder}`...` ``, -`` {sphinxext:domain}`...` ``). Targets accept fully-qualified dotted -paths or unambiguous bare class names, dangling references warn at -build time, and each domain ships a grouped components index. See +Two Sphinx domains — `docutils` and `sphinxext` — make every documented +component linkable from prose, e.g. {docutils:transform}`SanitizeTransform`, +with a grouped component index per domain. Component facts (Python +paths, translator classes, config types, registering entry points) +become links to their APIs where a target exists, and unresolved +references warn at build time. See {ref}`sphinx-autodoc-docutils-reference` for the role tables. (#53) ### Fixes From a568a2217ce268a28556db4feed2b45c09c39cb6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 14:26:20 -0500 Subject: [PATCH 33/33] docs(CHANGES) Show docutils role example as literal, not a live ref why: The tightened release notes rendered the {docutils:transform} example as a live cross-reference; SanitizeTransform is a django-docutils class with no target on gp-sphinx's own site, so the warn_dangling role failed the -W docs build in CI. what: - Wrap the role example in an inline code span so it shows the role syntax without resolving it --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index e17bae4f..1e62e814 100644 --- a/CHANGES +++ b/CHANGES @@ -46,7 +46,7 @@ demos. (#53) #### Cross-reference roles for documented components Two Sphinx domains — `docutils` and `sphinxext` — make every documented -component linkable from prose, e.g. {docutils:transform}`SanitizeTransform`, +component linkable from prose, e.g. `` {docutils:transform}`SanitizeTransform` ``, with a grouped component index per domain. Component facts (Python paths, translator classes, config types, registering entry points) become links to their APIs where a target exists, and unresolved