diff --git a/CHANGES b/CHANGES index 4c88b116..1e62e814 100644 --- a/CHANGES +++ b/CHANGES @@ -18,6 +18,41 @@ $ uv add gp-sphinx --prerelease allow +### What's new + +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) + +#### Autodoc for Sphinx builders and domains + +`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 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 #### Code comments no longer trigger a font swap on first paint diff --git a/docs/_ext/docutils_demo_components.py b/docs/_ext/docutils_demo_components.py new file mode 100644 index 00000000..5ade3a63 --- /dev/null +++ b/docs/_ext/docutils_demo_components.py @@ -0,0 +1,179 @@ +"""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.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 + 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) + + +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] + + +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() + + +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.""" + + 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. + + 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)) + ... 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)) + ... 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 + True + >>> ("add_source_parser", DemoLineParser) in fake.calls + 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, + "parallel_write_safe": True, + } diff --git a/docs/_ext/sphinx_demo_builder.py b/docs/_ext/sphinx_demo_builder.py new file mode 100644 index 00000000..9d412765 --- /dev/null +++ b/docs/_ext/sphinx_demo_builder.py @@ -0,0 +1,101 @@ +"""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 +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 + + 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.""" + + +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. + + 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)) + ... 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, + "parallel_write_safe": True, + } 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/examples.md b/docs/packages/sphinx-autodoc-docutils/examples.md index c2d9d015..b88b4949 100644 --- a/docs/packages/sphinx-autodoc-docutils/examples.md +++ b/docs/packages/sphinx-autodoc-docutils/examples.md @@ -40,6 +40,119 @@ 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: +``` + +### 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: +``` + +### 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: +``` + +### 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: +``` + +### 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: +``` + +### 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 +can link to them: {docutils:transform}`DemoReorderTransform` resolves +to the entry above, and {docutils:transform}`docutils_demo_components.DemoReorderTransform` +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 the live `setup()` calls. @@ -48,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-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..0fe1b999 --- /dev/null +++ b/docs/packages/sphinx-autodoc-docutils/reference.md @@ -0,0 +1,43 @@ +(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`. + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_docutils.setup +``` 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-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-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/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-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/examples.md b/docs/packages/sphinx-autodoc-sphinx/examples.md index be1cb940..7ea832c0 100644 --- a/docs/packages/sphinx-autodoc-sphinx/examples.md +++ b/docs/packages/sphinx-autodoc-sphinx/examples.md @@ -22,3 +22,66 @@ 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: +``` + +### 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` 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/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..4ddbbe98 100644 --- a/docs/packages/sphinx-autodoc-sphinx/reference.md +++ b/docs/packages/sphinx-autodoc-sphinx/reference.md @@ -13,3 +13,28 @@ 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`. + +## Extension entry point + +```{eval-rst} +.. autofunction:: sphinx_autodoc_sphinx.setup +``` 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/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 +``` 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 +``` 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-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 70c93a11..4f4b98d7 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -14,13 +14,87 @@ 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, + ParserInfo, + discover_parser, + discover_parsers, +) +from sphinx_autodoc_docutils._readers_doc import ( + AutoReader, + AutoReaders, + discover_reader, + discover_readers, +) +from sphinx_autodoc_docutils._transforms_doc import ( + AutoTransform, + AutoTransforms, + TransformInfo, + 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, + discover_writer, + discover_writers, +) +from sphinx_autodoc_docutils.domain import ( + DocutilsComponentIndex, + DocutilsDomain, +) __all__ = [ "AutoDirective", "AutoDirectives", + "AutoNode", + "AutoNodes", + "AutoParser", + "AutoParsers", + "AutoReader", + "AutoReaders", "AutoRole", "AutoRoles", + "AutoTransform", + "AutoTransforms", + "AutoTranslator", + "AutoTranslators", + "AutoWriter", + "AutoWriters", + "DocutilsComponentIndex", + "DocutilsDomain", + "NodeInfo", + "ParserInfo", "SetupRecorder", + "TransformInfo", + "TranslatorInfo", + "discover_node", + "discover_nodes", + "discover_parser", + "discover_parsers", + "discover_reader", + "discover_readers", + "discover_transform", + "discover_transforms", + "discover_translator", + "discover_translators", + "discover_writer", + "discover_writers", "replay_setup", "setup", ] @@ -44,6 +118,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 +128,10 @@ 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 True >>> ("add_css_file", "css/sphinx_autodoc_docutils.css") in fake.calls @@ -62,10 +142,23 @@ 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) 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) + app.add_directive("autoparser", AutoParser) + 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) + 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 5bc062de..a34a1f06 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,12 @@ "directive": SAB.TYPE_DIRECTIVE, "role": SAB.TYPE_ROLE, "option": SAB.TYPE_OPTION, + "transform": SAB.TYPE_TRANSFORM, + "reader": SAB.TYPE_READER, + "parser": SAB.TYPE_PARSER, + "writer": SAB.TYPE_WRITER, + "node": SAB.TYPE_NODE, + "translator": SAB.TYPE_TRANSLATOR, } @@ -27,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( @@ -39,3 +58,89 @@ def build_kind_badge_group(kind: str) -> nodes.inline: ], classes=[_GROUP_CLASS], ) + + +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. + + 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..eaa39d6c --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_components.py @@ -0,0 +1,296 @@ +"""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, + build_chip_paragraph, + build_linked_literal, + 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 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. + 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 + >>> classes = safe_transform_classes(Reader) + >>> any(cls.__name__ == "Transitions" for cls in classes) + True + + >>> safe_transform_classes(object) + [] + """ + try: + transforms = component_cls().get_transforms() + except Exception: # noqa: BLE001 — degrade to no facts on any component error + return [] + 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: + """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/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index a556b081..248d6ac5 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, @@ -270,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() @@ -295,20 +279,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 @@ -380,7 +395,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 +417,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( @@ -484,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}.", "", ] ) 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..4bf81e18 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_nodes_doc.py @@ -0,0 +1,257 @@ +"""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, + linked_paragraph, + render_component_nodes, +) +from sphinx_autodoc_docutils._directives import ( + _summary, + replay_setup, +) +from sphinx_autodoc_docutils.domain import NODE +from sphinx_ux_autodoc_layout import ( + ApiFactRow, + build_chip_paragraph, + build_linked_literal, +) + +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'] + """ + 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", 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)), + ), + ] + + +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-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..94a972e9 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_parsers_doc.py @@ -0,0 +1,229 @@ +"""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, + linked_paragraph, + 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, build_chip_paragraph + +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", linked_paragraph(info.qualified_name)), + ApiFactRow("Supported aliases", build_chip_paragraph(list(info.aliases))), + ApiFactRow( + "Config section", + _literal_paragraph(info.cls.config_section or "—"), + ), + ] + if info.registered_via: + rows.append( + ApiFactRow( + "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 + + +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-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..afd36db4 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_readers_doc.py @@ -0,0 +1,124 @@ +"""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, + linked_paragraph, + render_component_nodes, + 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, build_chip_paragraph + +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'] + """ + return [ + ApiFactRow( + "Python path", + linked_paragraph(f"{cls.__module__}.{cls.__name__}"), + ), + ApiFactRow("Supported formats", build_chip_paragraph(list(cls.supported))), + ApiFactRow( + "Config section", + _literal_paragraph(cls.config_section or "—"), + ), + ApiFactRow("Transforms", build_chip_paragraph(transform_chip_nodes(cls))), + ] + + +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-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..72cfb872 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_transforms_doc.py @@ -0,0 +1,241 @@ +"""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, + linked_paragraph, + 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", linked_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", + # 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 + + +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/_translators_doc.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py new file mode 100644 index 00000000..2286d2f3 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_translators_doc.py @@ -0,0 +1,245 @@ +"""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, + linked_paragraph, + 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, + build_chip_paragraph, + build_linked_literal, +) + +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'] + """ + 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", linked_paragraph(info.qualified_name)), + ApiFactRow( + "Base class", + linked_paragraph( + f"{base.__module__}.{base.__qualname__}", + base.__name__, + ), + ), + ApiFactRow("Overrides", build_chip_paragraph(override_chips)), + ] + 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-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..4c53b2b6 --- /dev/null +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_writers_doc.py @@ -0,0 +1,156 @@ +"""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, + linked_paragraph, + render_component_nodes, + 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, build_chip_paragraph + +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_body = ( + linked_paragraph(f"{translator.__module__}.{translator.__qualname__}") + if translator is not None + else _literal_paragraph("—") + ) + return [ + ApiFactRow( + "Python path", + linked_paragraph(f"{cls.__module__}.{cls.__name__}"), + ), + 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", build_chip_paragraph(transform_chip_nodes(cls))), + ] + + +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-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..5e94526e --- /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(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 + 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/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/) 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..7d592c8f 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py @@ -6,10 +6,46 @@ 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, ) +from sphinx_autodoc_sphinx._domains_doc import ( + AutoDomain, + AutoDomains, + DomainInfo, + discover_domain, + discover_domains, +) +from sphinx_autodoc_sphinx.domain import ( + SphinxExtComponentIndex, + SphinxExtDomain, +) + +__all__ = [ + "AutoBuilder", + "AutoBuilders", + "AutoDomain", + "AutoDomains", + "AutoconfigvalueDirective", + "AutoconfigvaluesDirective", + "BuilderInfo", + "DomainInfo", + "SphinxExtComponentIndex", + "SphinxExtDomain", + "discover_builder", + "discover_builders", + "discover_domain", + "discover_domains", + "setup", +] if t.TYPE_CHECKING: from sphinx.application import Sphinx @@ -30,6 +66,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 +76,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,8 +88,13 @@ 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) + 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 e33f181b..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( @@ -44,3 +60,89 @@ def build_config_badge_group(value: SphinxConfigValue) -> nodes.inline: ], classes=[_GROUP_CLASS], ) + + +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. + + 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..2cee2714 --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_builders_doc.py @@ -0,0 +1,228 @@ +"""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, + 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, build_chip_paragraph + +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 + # default_translator_class only exists on translator-driven + # builders (StandaloneHTMLBuilder and friends), not the base. + translator = getattr(cls, "default_translator_class", None) + translator_body = ( + linked_paragraph(f"{translator.__module__}.{translator.__qualname__}") + if inspect.isclass(translator) + else _literal_paragraph("—") + ) + return [ + 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", + 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 "—")), + ] + + +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..192f53ba --- /dev/null +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_components.py @@ -0,0 +1,312 @@ +"""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, + build_chip_paragraph, + build_linked_literal, + 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 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. + + 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-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py index 4e8a51aa..f800cea6 100644 --- a/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py +++ b/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/_directives.py @@ -25,16 +25,22 @@ 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, + build_chip_paragraph, + build_linked_literal, inject_signature_slots, iter_desc_nodes, parse_generated_markup, ) if t.TYPE_CHECKING: + from sphinx.environment import BuildEnvironment from sphinx.util.typing import OptionSpec _COMPLEX_REPR_THRESHOLD = 60 @@ -163,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 -------- @@ -175,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 @@ -379,25 +394,47 @@ 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", type_body), + ApiFactRow("Default", default_body), ApiFactRow( - "Type", - _literal_paragraph( - normalize_type_collection_text( - value.types, - default=value.default, - ) + "Registered by", + build_chip_paragraph( + [ + build_linked_literal( + f"{value.module_name}.setup", + f"{value.module_name}.setup()", + ), + ], ), ), - ApiFactRow("Default", default_body), - ApiFactRow("Registered by", _literal_paragraph(f"{value.module_name}.setup()")), ] @@ -414,7 +451,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/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..fe918a09 --- /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, + 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, build_chip_paragraph + +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 + return [ + 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", 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]), + ), + ] + + +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-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..7a3f605f --- /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(warn_dangling=True), + DOMAIN: XRefRole(warn_dangling=True), + } + + 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/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/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..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 @@ -77,6 +77,17 @@ "type", ) +_MANAGED_DOCUTILS_OBJTYPES: tuple[str, ...] = ( + "transform", + "reader", + "parser", + "writer", + "node", + "translator", +) + +_MANAGED_SPHINXEXT_OBJTYPES: tuple[str, ...] = ("builder", "domain") + @dataclasses.dataclass(frozen=True, slots=True) class DescLayoutProfile: @@ -129,6 +140,22 @@ 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 + }, + **{ + ("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 948e79fe..0e6b1ac6 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,25 @@ 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" + 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" + TYPE_TRANSLATOR = "gp-sphinx-badge--type-translator" + + # ── docutils component modifiers (outlined) ────────── + MOD_PRIORITY = "gp-sphinx-badge--mod-priority" + + # ── 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" 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..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 @@ -115,6 +115,51 @@ --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; + + --gp-sphinx-badge-reader-bg: #eef2ff; + --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; + + --gp-sphinx-badge-writer-bg: #f0fdfa; + --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; + + --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; + + /* ── Sphinx extension components (filled) ───────────── */ + --gp-sphinx-badge-builder-bg: #fffbeb; + --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; @@ -223,6 +268,47 @@ --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-reader-bg: #1e1b4b; + --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-writer-bg: #042f2e; + --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-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; + + --gp-sphinx-badge-builder-bg: #451a03; + --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; @@ -331,6 +417,47 @@ 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-reader-bg: #1e1b4b; + --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-writer-bg: #042f2e; + --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-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; + + --gp-sphinx-badge-builder-bg: #451a03; + --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; @@ -398,6 +525,33 @@ 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); } +.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); } +.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) + * ══════════════════════════════════════════════════════════ */ +.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); } +.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/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)", ] diff --git a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py index 331c312e..0a650a2b 100644 --- a/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py +++ b/tests/ext/autodoc_docutils/test_autodoc_docutils_integration.py @@ -20,7 +20,11 @@ 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 + from docutils.writers import Writer class DemoDirective(Directive): @@ -48,6 +52,75 @@ 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 + + + class DemoReader(Reader): + \"\"\"Read demo article sources.\"\"\" + + supported = ("demo-article",) + config_section = "demo reader" + + 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 + + + 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.\"\"\" + + 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) + app.add_node(demo_chip, html=(visit_demo_chip, depart_demo_chip)) + app.set_translator("demo-plain", DemoTranslator, override=True) """ ) @@ -73,6 +146,21 @@ 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: + + .. autoreader:: demo_docutils_objects.DemoReader + + .. autoparser:: demo_docutils_objects.DemoParser + + .. autowriter:: demo_docutils_objects.DemoWriter + + .. autonode:: demo_docutils_objects.demo_chip + + .. autotranslator:: demo_docutils_objects.DemoTranslator """ ) @@ -122,3 +210,89 @@ 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 + + +@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 + + +@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 + + +@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 + + +@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 + + +@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 new file mode 100644 index 00000000..22b4f088 --- /dev/null +++ b/tests/ext/autodoc_docutils/test_components.py @@ -0,0 +1,805 @@ +"""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._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, + _source_parsers_from_calls, + discover_parser, + discover_parsers, +) +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, + _transforms_from_calls, + 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, + discover_writers, + resolve_translator_class, +) +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__") + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + + +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 + + +# --------------------------------------------------------------------------- +# 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"] == "—" + + +# --------------------------------------------------------------------------- +# 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()" + + +# --------------------------------------------------------------------------- +# 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"] == "—" + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + + +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"] != "" + + +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" 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) 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/autodoc_docutils/test_domain_xref_integration.py b/tests/ext/autodoc_docutils/test_domain_xref_integration.py new file mode 100644 index 00000000..63d112aa --- /dev/null +++ b/tests/ext/autodoc_docutils/test_domain_xref_integration.py @@ -0,0 +1,185 @@ +"""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.ext.autodoc", + "sphinx_autodoc_docutils", + ] + """ +) + +_INDEX_RST = textwrap.dedent( + """\ + Component reference + =================== + + .. toctree:: + + usage + + .. autotransform:: demo_xref_components.DemoXrefTransform + + Full API + -------- + + .. automodule:: demo_xref_components + :members: + """ +) + +_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"] + + +@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 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_autodoc_sphinx_integration.py b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py index 3939b640..5c94b34f 100644 --- a/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py +++ b/tests/ext/autodoc_sphinx/test_autodoc_sphinx_integration.py @@ -19,8 +19,43 @@ """\ from __future__ import annotations + from sphinx.builders import Builder + from sphinx.domains import Domain, ObjType + from sphinx.roles import XRefRole + + + 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 + + + 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, @@ -40,6 +75,7 @@ def setup(app): types=(dict,), description="Color tokens for the demo extension.", ) + app.add_builder(DemoZipBuilder) """ ) @@ -63,6 +99,10 @@ def setup(app): =========== .. autoconfigvalues:: demo_sphinx_ext + + .. autobuilder:: demo_sphinx_ext.DemoZipBuilder + + .. autodomain:: demo_sphinx_ext.DemoRecipeDomain """ ) @@ -113,3 +153,57 @@ 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 + + +@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 + + +@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 new file mode 100644 index 00000000..558328d7 --- /dev/null +++ b/tests/ext/autodoc_sphinx/test_components.py @@ -0,0 +1,441 @@ +"""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_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 + +# --------------------------------------------------------------------------- +# 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"] == "—" + + +# --------------------------------------------------------------------------- +# 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"] == "—" + + +# --------------------------------------------------------------------------- +# 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 + + +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()" + + +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)) 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"), ], 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 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"] 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}" 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" 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)) 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", ]