From cd48069a8f2a2ca3c6f623888ce9579a98b8723d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 14:49:26 -0500 Subject: [PATCH 1/7] feat(pytest-plugin[docs]): Add doc-pytest-plugin directive why: Downstream projects need a reusable way to publish pytest plugin pages with install guidance, autodetection notes, and generated fixture reference. what: - add doc-pytest-plugin to sphinx-autodoc-pytest-fixtures - cover page/reference modes and no-fixture warnings in integration tests - document the new helper in the package docs and README --- .../sphinx-autodoc-pytest-fixtures.md | 17 +- .../sphinx-autodoc-pytest-fixtures/README.md | 6 + .../__init__.py | 2 + .../_directives.py | 360 +++++++++++++++--- ...test_sphinx_pytest_fixtures_integration.py | 256 +++++++++++-- 5 files changed, 563 insertions(+), 78 deletions(-) diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index 4bc85d69..dd45e10c 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -4,7 +4,8 @@ Sphinx extension for documenting pytest fixtures as first-class objects. It registers a Python-domain fixture directive and role, autodoc helpers for bulk -fixture discovery, and the badge/index UI used throughout the page below. +fixture discovery, a higher-level pytest plugin page helper, and the +badge/index UI used throughout the page below. ```console $ pip install sphinx-autodoc-pytest-fixtures @@ -51,6 +52,20 @@ pytest_external_fixture_links = { .. autofixtures:: spf_demo_fixtures ``` +### Plugin page helper + +```{eval-rst} +.. doc-pytest-plugin:: spf_demo_fixtures + :project: spf-demo + :package: sphinx-autodoc-pytest-fixtures + :summary: Use this helper to generate a polished pytest plugin page with + install, autodetection, and fixture reference sections. + :mode: reference + + Add project-specific usage notes here, then let the helper render the + shared fixture summary and reference sections. +``` + #### autofixtures options | Option | Default | Description | diff --git a/packages/sphinx-autodoc-pytest-fixtures/README.md b/packages/sphinx-autodoc-pytest-fixtures/README.md index d78a30ee..313629d0 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/README.md +++ b/packages/sphinx-autodoc-pytest-fixtures/README.md @@ -24,6 +24,12 @@ Then document fixtures with: .. autofixtures:: myproject.conftest .. autofixture-index:: myproject.conftest + +.. doc-pytest-plugin:: myproject.pytest_plugin + :project: myproject + :package: myproject + :summary: Document your pytest plugin with generated install and fixture + reference sections. ``` ## Documentation diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py index 146a4984..e73eb7fe 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py @@ -53,6 +53,7 @@ from sphinx_autodoc_pytest_fixtures._directives import ( AutofixtureIndexDirective, AutofixturesDirective, + DocPytestPluginDirective, PyFixtureDirective, ) from sphinx_autodoc_pytest_fixtures._documenter import FixtureDocumenter @@ -174,6 +175,7 @@ def _add_static_path(app: Sphinx) -> None: app.add_directive("autofixtures", AutofixturesDirective) app.add_node(autofixture_index_node) app.add_directive("autofixture-index", AutofixtureIndexDirective) + app.add_directive("doc-pytest-plugin", DocPytestPluginDirective) app.connect("missing-reference", _on_missing_reference) app.connect("doctree-resolved", _on_doctree_resolved) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py index 42d212ad..f105f2ef 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py @@ -2,11 +2,12 @@ from __future__ import annotations +import importlib +import pathlib import typing as t from docutils import nodes -from docutils.parsers.rst import Directive, directives -from docutils.statemachine import ViewList +from docutils.parsers.rst import directives from sphinx import addnodes from sphinx.domains.python import PyFunction from sphinx.util import logging as sphinx_logging @@ -46,6 +47,133 @@ logger = sphinx_logging.getLogger(__name__) +def _iter_public_fixture_entries( + module: t.Any, + *, + excluded: set[str] | None = None, +) -> list[tuple[str, str, t.Any]]: + """Collect public pytest fixtures from a module. + + Parameters + ---------- + module : Any + Imported module object to scan. + excluded : set[str] | None, optional + Public fixture names to skip. + + Returns + ------- + list[tuple[str, str, Any]] + Tuples of ``(attr_name, public_name, fixture_obj)`` in source order. + """ + excluded = excluded or set() + entries: list[tuple[str, str, t.Any]] = [] + seen_public: set[str] = set() + + for attr_name, value in vars(module).items(): + if not _is_pytest_fixture(value): + continue + try: + marker = _get_fixture_marker(value) + except AttributeError: + continue + public_name = marker.name or _get_fixture_fn(value).__name__ + if public_name in excluded: + continue + if public_name in seen_public: + logger.warning( + "pytest fixture scan skipped duplicate public name %r in %s", + public_name, + module.__name__, + ) + continue + seen_public.add(public_name) + entries.append((attr_name, public_name, value)) + + return entries + + +def _note_module_dependency(document: nodes.document, module: t.Any) -> None: + """Register an imported module file as a Sphinx dependency. + + Parameters + ---------- + document : nodes.document + Current document node carrying the Sphinx environment. + module : Any + Imported module object that may expose ``__file__``. + """ + env = document.settings.env + if hasattr(module, "__file__") and module.__file__: + env.note_dependency(module.__file__) + + +def _render_autofixtures_nodes( + directive: SphinxDirective, + *, + modname: str, + entries: list[tuple[str, str, t.Any]], + order: str = "source", +) -> list[nodes.Node]: + """Render ``autofixture`` directives into doctree nodes. + + Parameters + ---------- + directive : SphinxDirective + Directive instance used for nested parsing. + modname : str + Imported fixture module name. + entries : list[tuple[str, str, Any]] + Public fixture entries as ``(attr_name, public_name, fixture_obj)``. + order : str, optional + ``"source"`` keeps module order; ``"alpha"`` sorts by public name. + + Returns + ------- + list[nodes.Node] + Parsed fixture reference nodes. + """ + if order == "alpha": + entries = sorted(entries, key=lambda entry: entry[1]) + + lines: list[str] = [] + for _attr_name, public_name, _value in entries: + lines.append(f".. autofixture:: {modname}.{public_name}") + lines.append("") + + content = "\n".join(lines).strip() + if _is_markdown_source(directive): + content = f"```{{eval-rst}}\n{content}\n```" + + return directive.parse_text_to_nodes( + content, + offset=directive.content_offset, + ) + + +def _is_markdown_source(directive: SphinxDirective) -> bool: + """Return ``True`` when the current document source is Markdown/MyST.""" + source, _line = directive.get_source_info() + if not source: + source = getattr(directive.state.document, "current_source", "") + if not source: + return False + + return pathlib.Path(source).suffix.lower() in { + ".md", + ".markdown", + ".myst", + } + + +def _build_doc_pytest_plugin_index_node(modname: str) -> autofixture_index_node: + """Create an autofixture-index placeholder node for *modname*.""" + node = autofixture_index_node() + node["module"] = modname + node["exclude"] = set() + return node + + class PyFixtureDirective(PyFunction): """Sphinx directive for documenting pytest fixtures: ``.. py:fixture::``. @@ -442,7 +570,7 @@ def add_target_and_index( ) -class AutofixturesDirective(Directive): +class AutofixturesDirective(SphinxDirective): """Bulk fixture autodoc directive: ``.. autofixtures:: module.name``. Scans *module.name* for all pytest fixtures and emits one @@ -474,8 +602,6 @@ class AutofixturesDirective(Directive): def run(self) -> list[nodes.Node]: """Scan the module and emit autofixture directives.""" - import importlib - modname = self.arguments[0].strip() order = self.options.get("order", "source") exclude_str = self.options.get("exclude", "") @@ -492,57 +618,181 @@ def run(self) -> list[nodes.Node]: ) return [] - # Register the module file as a dependency so incremental rebuilds - # re-process this page when the scanned module changes. - env = self.state.document.settings.env - if hasattr(module, "__file__") and module.__file__: - env.note_dependency(module.__file__) - - # Collect all (attr_name, public_name, fixture_obj) triples. - entries: list[tuple[str, str, t.Any]] = [] - seen_public: set[str] = set() - for attr_name, value in vars(module).items(): - if not _is_pytest_fixture(value): - continue - try: - marker = _get_fixture_marker(value) - except AttributeError: - continue - public_name = marker.name or _get_fixture_fn(value).__name__ - if public_name in excluded: - continue - if public_name in seen_public: - logger.warning( - "autofixtures: duplicate public name %r in %s; skipping duplicate.", - public_name, - modname, - ) - continue - seen_public.add(public_name) - entries.append((attr_name, public_name, value)) - - if order == "alpha": - entries.sort(key=lambda e: e[1]) - - # Build RST content: one ``autofixture::`` directive per fixture. - source = f"" - lines: list[str] = [] - for _attr_name, public_name, _value in entries: - lines.append(f".. autofixture:: {modname}.{public_name}") - lines.append("") - rst_lines = ViewList(lines, source=source) - - # Parse the generated RST into a container node. - # ViewList is compatible with nested_parse at runtime even though - # docutils stubs declare StringList — suppress the type mismatch. - container = nodes.section() - container.document = self.state.document - self.state.nested_parse( - rst_lines, # type: ignore[arg-type] - self.content_offset, - container, + _note_module_dependency(self.state.document, module) + entries = _iter_public_fixture_entries(module, excluded=excluded) + + return _render_autofixtures_nodes( + self, + modname=modname, + entries=entries, + order=order, + ) + + +class DocPytestPluginDirective(SphinxDirective): + """Render a reusable pytest-plugin documentation page block. + + Parameters + ---------- + self : SphinxDirective + Directive instance populated by the Sphinx parser. + + Notes + ----- + ``page`` mode emits a compact install/autodiscovery intro before the + generated fixture summary and reference blocks. ``reference`` mode only + emits any authored body content plus the generated fixture sections. + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = True + option_spec: t.ClassVar[dict[str, t.Any]] = { + "project": directives.unchanged, + "package": directives.unchanged, + "summary": directives.unchanged, + "mode": lambda arg: directives.choice(arg, ("page", "reference")), + "tests-url": directives.unchanged, + "install-command": directives.unchanged, + } + + def run(self) -> list[nodes.Node]: + """Render intro prose plus generated fixture index/reference blocks.""" + modname = self.arguments[0].strip() + project = self._require_option("project") + package = self._require_option("package") + summary = self._require_option("summary") + mode = self.options.get("mode", "page") + tests_url = self.options.get("tests-url") + install_command = self.options.get( + "install-command", + f"pip install {package}", + ) + + children: list[nodes.Node] = [] + if mode == "page": + children.extend( + self._build_page_intro_nodes( + project=project, + summary=summary, + install_command=install_command, + tests_url=tests_url, + ), + ) + + if self.content: + children.extend( + self.parse_content_to_nodes( + allow_section_headings=True, + ), + ) + + entries = self._get_module_fixture_entries(modname) + if entries is not None: + children.extend( + self._build_fixture_section_nodes( + modname=modname, + entries=entries, + ), + ) + + return children + + def _require_option(self, name: str) -> str: + """Return a required option value or raise a directive error.""" + value = self.options.get(name, "").strip() + if not value: + msg = f"{self.name} requires the :{name}: option" + raise self.error(msg) + return value + + def _get_module_fixture_entries( + self, + modname: str, + ) -> list[tuple[str, str, t.Any]] | None: + """Import *modname* and return discovered fixture entries.""" + try: + module = importlib.import_module(modname) + except ImportError: + logger.warning( + "doc-pytest-plugin could not import module %r; " + "skipping generated fixture sections", + modname, + ) + return None + + _note_module_dependency(self.state.document, module) + entries = _iter_public_fixture_entries(module) + if entries: + return entries + + logger.warning( + "doc-pytest-plugin found no pytest fixtures in %r; " + "skipping generated fixture sections", + modname, + ) + return None + + def _build_page_intro_nodes( + self, + *, + project: str, + summary: str, + install_command: str, + tests_url: str | None, + ) -> list[nodes.Node]: + """Build the generated intro nodes for ``page`` mode.""" + intro_nodes: list[nodes.Node] = [nodes.paragraph("", summary)] + intro_nodes.append(nodes.rubric("", "Install")) + + install_block = nodes.literal_block("", f"$ {install_command}") + install_block["language"] = "console" + intro_nodes.append(install_block) + + note = nodes.note() + note_para = nodes.paragraph() + note_para += nodes.Text("pytest auto-detects this plugin through the ") + note_para += nodes.literal("", "pytest11") + note_para += nodes.Text( + " entry point. Its fixtures are available without extra " ) - return container.children + note_para += nodes.literal("", "conftest.py") + note_para += nodes.Text(" imports.") + note += note_para + intro_nodes.append(note) + + if tests_url: + tests_para = nodes.paragraph() + tests_para += nodes.Text("For real-world usage examples, see the ") + tests_para += nodes.reference( + "", + "", + nodes.Text(f"{project} test suite"), + refuri=tests_url, + ) + tests_para += nodes.Text(".") + intro_nodes.append(tests_para) + + return intro_nodes + + def _build_fixture_section_nodes( + self, + *, + modname: str, + entries: list[tuple[str, str, t.Any]], + ) -> list[nodes.Node]: + """Build generated fixture summary/reference nodes.""" + return [ + nodes.rubric("", "Fixture Summary"), + _build_doc_pytest_plugin_index_node(modname), + nodes.rubric("", "Fixture Reference"), + *_render_autofixtures_nodes( + self, + modname=modname, + entries=entries, + order="source", + ), + ] class AutofixtureIndexDirective(SphinxDirective): diff --git a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py index adf42d21..48303b60 100644 --- a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py +++ b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py @@ -80,8 +80,7 @@ def _internal_name() -> str: sys.path.insert(0, "{srcdir}") extensions = [ - "sphinx.ext.autodoc", - "sphinx_autodoc_pytest_fixtures", +{extensions} ] master_doc = "index" @@ -89,6 +88,25 @@ def _internal_name() -> str: html_theme = "alabaster" """ + +def _render_conf_py( + srcdir: pathlib.Path, + *, + extensions: list[str] | None = None, +) -> str: + """Render ``conf.py`` for the synthetic Sphinx project.""" + if extensions is None: + extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_pytest_fixtures", + ] + rendered_extensions = ",\n".join(f' "{ext}"' for ext in extensions) + return CONF_PY_TEMPLATE.format( + srcdir=str(srcdir), + extensions=rendered_extensions, + ) + + INDEX_RST = textwrap.dedent( """\ Test fixtures @@ -154,6 +172,8 @@ def _build_sphinx_app( confoverrides: dict[str, t.Any] | None = None, fixture_source: str | None = None, index_rst: str | None = None, + index_name: str = "index.rst", + extensions: list[str] | None = None, ) -> _SphinxResult: """Write project files and run a full Sphinx HTML build; return results. @@ -169,6 +189,10 @@ def _build_sphinx_app( index_rst : Override the RST index written to ``index.rst``. Defaults to :data:`INDEX_RST`. + index_name : + Index filename to write, such as ``index.rst`` or ``index.md``. + extensions : + Optional extension list for ``conf.py``. """ from sphinx.application import Sphinx @@ -185,10 +209,10 @@ def _build_sphinx_app( encoding="utf-8", ) (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), + _render_conf_py(srcdir, extensions=extensions), encoding="utf-8", ) - (srcdir / "index.rst").write_text( + (srcdir / index_name).write_text( index_rst if index_rst is not None else INDEX_RST, encoding="utf-8", ) @@ -289,10 +313,7 @@ def test_manual_directive_without_module(tmp_path: pathlib.Path) -> None: doctreedir.mkdir() (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) + (srcdir / "conf.py").write_text(_render_conf_py(srcdir), encoding="utf-8") # Bare directive with no currentmodule (srcdir / "index.rst").write_text( "Manual\n======\n\n.. py:fixture:: bare_server\n\n Bare server docs.\n", @@ -334,10 +355,7 @@ def test_xref_resolves(tmp_path: pathlib.Path) -> None: doctreedir.mkdir() (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) + (srcdir / "conf.py").write_text(_render_conf_py(srcdir), encoding="utf-8") (srcdir / "index.rst").write_text( textwrap.dedent( """\ @@ -459,10 +477,7 @@ def needs_tmp(tmp_path: "pathlib.Path") -> str: doctreedir.mkdir() (srcdir / "fixture_mod.py").write_text(src, encoding="utf-8") - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) + (srcdir / "conf.py").write_text(_render_conf_py(srcdir), encoding="utf-8") (srcdir / "index.rst").write_text( "Fixtures\n========\n\n.. py:module:: fixture_mod\n\n" ".. autofixture:: fixture_mod.needs_tmp\n", @@ -506,10 +521,7 @@ def test_kind_override_hook_option(tmp_path: pathlib.Path) -> None: doctreedir.mkdir() (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") - (srcdir / "conf.py").write_text( - CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), - encoding="utf-8", - ) + (srcdir / "conf.py").write_text(_render_conf_py(srcdir), encoding="utf-8") (srcdir / "index.rst").write_text( textwrap.dedent( """\ @@ -1548,6 +1560,206 @@ def test_autofixtures_directive_import_error(tmp_path: pathlib.Path) -> None: assert "nonexistent_module_xyz_12345" in result.warnings +# --------------------------------------------------------------------------- +# doc-pytest-plugin directive +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_doc_pytest_plugin_page_mode(tmp_path: pathlib.Path) -> None: + """page mode renders install, autodetection, body, and fixture sections.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. doc-pytest-plugin:: fixture_mod + :project: fixture-demo + :package: fixture-demo + :summary: fixture-demo ships a pytest plugin for local test setup. + :tests-url: https://example.com/fixture-demo/tests + :install-command: uv add --dev fixture-demo + + Use the plugin when you want isolated test resources with minimal + conftest boilerplate. + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "fixture-demo ships a pytest plugin for local test setup" in html + assert "highlight-console" in html + assert "--dev" in html + assert "pytest auto-detects this plugin through the" in html + assert "fixture-demo test suite" in html + assert "Use the plugin when you want isolated test resources" in html + assert "Fixture Summary" in html + assert "Fixture Reference" in html + assert "my_server" in html + + +@pytest.mark.integration +def test_doc_pytest_plugin_reference_mode(tmp_path: pathlib.Path) -> None: + """reference mode skips the generated intro but keeps body and fixtures.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. doc-pytest-plugin:: fixture_mod + :project: fixture-demo + :package: fixture-demo + :summary: fixture-demo ships a pytest plugin for local test setup. + :mode: reference + + Keep this page focused on the fixture catalogue. + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "Keep this page focused on the fixture catalogue" in html + assert "uv add --dev fixture-demo" not in html + assert "pytest auto-detects this plugin through the" not in html + assert "Fixture Summary" in html + assert "Fixture Reference" in html + + +@pytest.mark.integration +def test_doc_pytest_plugin_warns_when_module_has_no_fixtures( + tmp_path: pathlib.Path, +) -> None: + """Modules with no fixtures warn and omit generated fixture sections.""" + fixture_source = textwrap.dedent( + """\ + from __future__ import annotations + + def helper() -> str: + return "helper" + """, + ) + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. doc-pytest-plugin:: fixture_mod + :project: fixture-demo + :package: fixture-demo + :summary: fixture-demo ships a pytest plugin for local test setup. + """, + ) + result = _build_sphinx_app( + tmp_path, + fixture_source=fixture_source, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "Fixture Summary" not in html + assert "Fixture Reference" not in html + assert "found no pytest fixtures" in result.warnings + + +@pytest.mark.integration +def test_doc_pytest_plugin_myst_page_mode(tmp_path: pathlib.Path) -> None: + """MyST pages render headings, code fences, and fixture sections correctly.""" + index_md = textwrap.dedent( + """\ + # Test fixtures + + :::{doc-pytest-plugin} fixture_mod + :project: fixture-demo + :package: fixture-demo + :summary: fixture-demo ships a pytest plugin for local test setup. + :tests-url: https://example.com/fixture-demo/tests + + ## Recommended fixtures + + Use this page when you want generated fixture docs and authored notes. + + ## Bootstrapping in `conftest.py` + + ```python + import pytest + + + @pytest.fixture(autouse=True) + def setup(my_server: str) -> None: + pass + ``` + ::: + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_md, + index_name="index.md", + extensions=[ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx_autodoc_pytest_fixtures", + ], + confoverrides={ + "pytest_fixture_lint_level": "none", + "myst_enable_extensions": ["colon_fence"], + }, + ) + html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "Recommended fixtures" in html + assert "Bootstrapping in" in html + assert "highlight-python" in html + assert "Fixture Summary" in html + assert "Fixture Reference" in html + assert "fake server" in html + assert ".. rubric::" not in html + assert ".. autofixture-index::" not in html + assert ".. autofixtures::" not in html + assert ".. autofixture::" not in html + + +@pytest.mark.integration +def test_autofixtures_directive_myst_page_mode(tmp_path: pathlib.Path) -> None: + """MyST pages render ``autofixtures`` output instead of leaking directives.""" + index_md = textwrap.dedent( + """\ + # Test fixtures + + :::{eval-rst} + .. py:module:: fixture_mod + ::: + + :::{autofixtures} fixture_mod + :order: source + ::: + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_md, + index_name="index.md", + extensions=[ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx_autodoc_pytest_fixtures", + ], + confoverrides={ + "pytest_fixture_lint_level": "none", + "myst_enable_extensions": ["colon_fence"], + }, + ) + html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "fake server" in html + assert "my_client" in html + assert ".. autofixture::" not in html + + # --------------------------------------------------------------------------- # Search pair index entries # --------------------------------------------------------------------------- @@ -1741,7 +1953,7 @@ def test_cross_doc_used_by_link(tmp_path: pathlib.Path) -> None: doctreedir.mkdir() (srcdir / "fixture_mod.py").write_text(CROSS_DOC_FIXTURE_SOURCE, encoding="utf-8") - conf = CONF_PY_TEMPLATE.format(srcdir=str(srcdir)) + conf = _render_conf_py(srcdir) (srcdir / "conf.py").write_text(conf, encoding="utf-8") (srcdir / "index.rst").write_text(CROSS_DOC_INDEX_RST, encoding="utf-8") (srcdir / "api.rst").write_text(CROSS_DOC_API_RST, encoding="utf-8") @@ -1828,7 +2040,7 @@ def test_text_builder_does_not_crash(tmp_path: pathlib.Path) -> None: doctreedir.mkdir() (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") - conf_py = CONF_PY_TEMPLATE.format(srcdir=str(srcdir)) + conf_py = _render_conf_py(srcdir) (srcdir / "conf.py").write_text(conf_py, encoding="utf-8") (srcdir / "index.rst").write_text(INDEX_RST, encoding="utf-8") From 625e1ed3d2af54d1c2910f68dcd6d2989e30777f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 17:03:09 -0500 Subject: [PATCH 2/7] fix(pytest-plugin[directives]): Fix NameError in _build_fixture_section_nodes why: _build_doc_pytest_plugin_index_node was removed but the call site was not updated, crashing every docs build that uses doc-pytest-plugin. what: - Inline autofixture_index_node() construction with required ["module"] and ["exclude"] attributes - No new imports needed; autofixture_index_node already imported at line 41 --- .../sphinx_autodoc_pytest_fixtures/_directives.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py index f105f2ef..8bf30ebf 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py @@ -166,14 +166,6 @@ def _is_markdown_source(directive: SphinxDirective) -> bool: } -def _build_doc_pytest_plugin_index_node(modname: str) -> autofixture_index_node: - """Create an autofixture-index placeholder node for *modname*.""" - node = autofixture_index_node() - node["module"] = modname - node["exclude"] = set() - return node - - class PyFixtureDirective(PyFunction): """Sphinx directive for documenting pytest fixtures: ``.. py:fixture::``. @@ -782,9 +774,12 @@ def _build_fixture_section_nodes( entries: list[tuple[str, str, t.Any]], ) -> list[nodes.Node]: """Build generated fixture summary/reference nodes.""" + idx_node = autofixture_index_node() + idx_node["module"] = modname + idx_node["exclude"] = set() return [ nodes.rubric("", "Fixture Summary"), - _build_doc_pytest_plugin_index_node(modname), + idx_node, nodes.rubric("", "Fixture Reference"), *_render_autofixtures_nodes( self, From 9752c234f2b52effe0a157eabfc1961481b7d698 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 17:04:56 -0500 Subject: [PATCH 3/7] refactor(pytest-plugin[api]): Remove mode option and relax required options why: mode: reference duplicates the manual recipe inside the helper, undermining the abstraction. :summary: multi-line values cause RST parse warnings. :project: is redundant when it equals :package:. what: - Remove mode from option_spec; intro block now always emits - Make :summary: optional with guard in _build_page_intro_nodes - Make :project: optional; empty string -> generic "test suite" link text (not package slug default) - Fix docs demo from eval-rst + mode: reference to native MyST colon-fence - Delete test_doc_pytest_plugin_reference_mode (tests removed behavior) - Update class docstring --- .../sphinx-autodoc-pytest-fixtures.md | 17 +++----- .../_directives.py | 43 +++++++++---------- ...test_sphinx_pytest_fixtures_integration.py | 30 ------------- 3 files changed, 27 insertions(+), 63 deletions(-) diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index dd45e10c..a72ef421 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -54,17 +54,12 @@ pytest_external_fixture_links = { ### Plugin page helper -```{eval-rst} -.. doc-pytest-plugin:: spf_demo_fixtures - :project: spf-demo - :package: sphinx-autodoc-pytest-fixtures - :summary: Use this helper to generate a polished pytest plugin page with - install, autodetection, and fixture reference sections. - :mode: reference - - Add project-specific usage notes here, then let the helper render the - shared fixture summary and reference sections. -``` +:::{doc-pytest-plugin} spf_demo_fixtures +:package: sphinx-autodoc-pytest-fixtures + +Add project-specific usage notes here. The helper renders the install +section, autodiscovery note, and full fixture summary/reference. +::: #### autofixtures options diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py index 8bf30ebf..a8670c12 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py @@ -624,16 +624,15 @@ def run(self) -> list[nodes.Node]: class DocPytestPluginDirective(SphinxDirective): """Render a reusable pytest-plugin documentation page block. + Always emits an install section, pytest11 autodiscovery note, and + generated fixture summary/reference. The directive body is free-form: + ``allow_section_headings=True`` is intentional so projects can add + "Recommended fixtures" and similar sections inline. + Parameters ---------- self : SphinxDirective Directive instance populated by the Sphinx parser. - - Notes - ----- - ``page`` mode emits a compact install/autodiscovery intro before the - generated fixture summary and reference blocks. ``reference`` mode only - emits any authored body content plus the generated fixture sections. """ required_arguments = 1 @@ -643,7 +642,6 @@ class DocPytestPluginDirective(SphinxDirective): "project": directives.unchanged, "package": directives.unchanged, "summary": directives.unchanged, - "mode": lambda arg: directives.choice(arg, ("page", "reference")), "tests-url": directives.unchanged, "install-command": directives.unchanged, } @@ -651,10 +649,9 @@ class DocPytestPluginDirective(SphinxDirective): def run(self) -> list[nodes.Node]: """Render intro prose plus generated fixture index/reference blocks.""" modname = self.arguments[0].strip() - project = self._require_option("project") package = self._require_option("package") - summary = self._require_option("summary") - mode = self.options.get("mode", "page") + project = self.options.get("project", "").strip() + summary = self.options.get("summary", "").strip() tests_url = self.options.get("tests-url") install_command = self.options.get( "install-command", @@ -662,15 +659,14 @@ def run(self) -> list[nodes.Node]: ) children: list[nodes.Node] = [] - if mode == "page": - children.extend( - self._build_page_intro_nodes( - project=project, - summary=summary, - install_command=install_command, - tests_url=tests_url, - ), - ) + children.extend( + self._build_page_intro_nodes( + project=project, + summary=summary, + install_command=install_command, + tests_url=tests_url, + ), + ) if self.content: children.extend( @@ -733,8 +729,10 @@ def _build_page_intro_nodes( install_command: str, tests_url: str | None, ) -> list[nodes.Node]: - """Build the generated intro nodes for ``page`` mode.""" - intro_nodes: list[nodes.Node] = [nodes.paragraph("", summary)] + """Build the generated intro nodes.""" + intro_nodes: list[nodes.Node] = [] + if summary: + intro_nodes.append(nodes.paragraph("", summary)) intro_nodes.append(nodes.rubric("", "Install")) install_block = nodes.literal_block("", f"$ {install_command}") @@ -754,12 +752,13 @@ def _build_page_intro_nodes( intro_nodes.append(note) if tests_url: + link_text = f"{project} test suite" if project else "test suite" tests_para = nodes.paragraph() tests_para += nodes.Text("For real-world usage examples, see the ") tests_para += nodes.reference( "", "", - nodes.Text(f"{project} test suite"), + nodes.Text(link_text), refuri=tests_url, ) tests_para += nodes.Text(".") diff --git a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py index 48303b60..b5a7056e 100644 --- a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py +++ b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py @@ -1601,36 +1601,6 @@ def test_doc_pytest_plugin_page_mode(tmp_path: pathlib.Path) -> None: assert "my_server" in html -@pytest.mark.integration -def test_doc_pytest_plugin_reference_mode(tmp_path: pathlib.Path) -> None: - """reference mode skips the generated intro but keeps body and fixtures.""" - index_rst = textwrap.dedent( - """\ - Test fixtures - ============= - - .. doc-pytest-plugin:: fixture_mod - :project: fixture-demo - :package: fixture-demo - :summary: fixture-demo ships a pytest plugin for local test setup. - :mode: reference - - Keep this page focused on the fixture catalogue. - """, - ) - result = _build_sphinx_app( - tmp_path, - index_rst=index_rst, - confoverrides={"pytest_fixture_lint_level": "none"}, - ) - html = (result.outdir / "index.html").read_text(encoding="utf-8") - assert "Keep this page focused on the fixture catalogue" in html - assert "uv add --dev fixture-demo" not in html - assert "pytest auto-detects this plugin through the" not in html - assert "Fixture Summary" in html - assert "Fixture Reference" in html - - @pytest.mark.integration def test_doc_pytest_plugin_warns_when_module_has_no_fixtures( tmp_path: pathlib.Path, From fefaaf518e755eb15b89e0f3779051e37bdbb335 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 17:05:42 -0500 Subject: [PATCH 4/7] test(pytest-plugin[directives]): Add coverage for simplified doc-pytest-plugin API why: New optional :project: and :summary: options and removed :mode: need dedicated tests to prevent regressions. what: - test_doc_pytest_plugin_project_defaults_to_generic_link: :project: absent -> "test suite" generic text - test_doc_pytest_plugin_summary_optional: no :summary: -> no empty

, page still renders - test_doc_pytest_plugin_missing_package_fails_build: :package: absent -> self.error() fires --- ...test_sphinx_pytest_fixtures_integration.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py index b5a7056e..d6f96aef 100644 --- a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py +++ b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py @@ -1637,6 +1637,80 @@ def helper() -> str: assert "found no pytest fixtures" in result.warnings +@pytest.mark.integration +def test_doc_pytest_plugin_project_defaults_to_generic_link( + tmp_path: pathlib.Path, +) -> None: + """When :project: is absent, tests-url link uses generic 'test suite' text.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. doc-pytest-plugin:: fixture_mod + :package: fixture-demo + :tests-url: https://example.com/fixture-demo/tests + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "test suite" in html + assert "fixture-demo test suite" not in html + + +@pytest.mark.integration +def test_doc_pytest_plugin_summary_optional(tmp_path: pathlib.Path) -> None: + """Omitting :summary: produces a valid page; no empty paragraph emitted.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. doc-pytest-plugin:: fixture_mod + :package: fixture-demo + + Body prose only. + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "pytest auto-detects this plugin through the" in html + assert "Fixture Summary" in html + assert "Body prose only" in html + assert "

" not in html + + +@pytest.mark.integration +def test_doc_pytest_plugin_missing_package_fails_build( + tmp_path: pathlib.Path, +) -> None: + """Missing required :package: option raises a directive error.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. doc-pytest-plugin:: fixture_mod + + No package option here. + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + assert "requires the :package: option" in result.warnings + + @pytest.mark.integration def test_doc_pytest_plugin_myst_page_mode(tmp_path: pathlib.Path) -> None: """MyST pages render headings, code fences, and fixture sections correctly.""" From 71ae109071669f8ded873b65bf3e3d8c07ae4173 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 17:06:19 -0500 Subject: [PATCH 5/7] docs(pytest-plugin[api]): Document easy-path and manual recipe why: Users need to know when to graduate from the directive to the manual recipe without discovering it by accident. what: - Add "When to use doc-pytest-plugin" section - Add "Manual recipe (power path)" section with concrete example --- .../sphinx-autodoc-pytest-fixtures.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index a72ef421..011dcc8d 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -61,6 +61,30 @@ Add project-specific usage notes here. The helper renders the install section, autodiscovery note, and full fixture summary/reference. ::: +#### When to use `doc-pytest-plugin` + +Use this directive for a standard pytest plugin page where you want consistent +house-style: an install section, the `pytest11` autodiscovery note, and a +generated fixture summary and reference. + +#### Manual recipe (power path) + +When you need custom layout — a different section order, content between the +summary and reference, or no install block — use the low-level directives +directly: + +````markdown +## Fixture Summary + +```{autofixture-index} libvcs.pytest_plugin +``` + +## Fixture Reference + +```{autofixtures} libvcs.pytest_plugin +``` +```` + #### autofixtures options | Option | Default | Description | From db5e7b175e95762e95c8635e755fd194f85037a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 18:26:14 -0500 Subject: [PATCH 6/7] fix(pytest-plugin[docs]): Fix docs build warnings from autofixtures in eval-rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Two categories of warning were failing docs CI under sphinx-build -W. (1) _is_markdown_source checked the outer file's .md extension, returning True even for directives inside {eval-rst} blocks. Those directives use a docutils RST Body state whose nested_parse is pure RST — passing a {eval-rst} backtick fence to it caused spurious "Inline literal start-string without end-string" and "Unexpected indentation" warnings. The correct check is whether the directive was invoked natively by MyST, which is detectable via isinstance(directive.state, MockState). (2) The demo page documented spf_demo_fixtures twice (autofixtures:: block + doc-pytest-plugin section) without :no-index: on either, triggering 9 "duplicate object description" warnings. what: - Replace _is_markdown_source with _is_native_myst (MockState isinstance check); eval-rst-embedded directives now skip the {eval-rst} wrap - Add no_index parameter to _render_autofixtures_nodes to emit :no-index: on each generated autofixture directive when requested - Add "no-index" flag option to AutofixturesDirective; pass it through to _render_autofixtures_nodes - Add :no-index: to the demo autofixtures:: spf_demo_fixtures eval-rst block so doc-pytest-plugin remains the canonical fixture reference - Document :no-index: option in the autofixtures options table --- .../sphinx-autodoc-pytest-fixtures.md | 2 + .../_directives.py | 48 ++++++++++++------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/packages/sphinx-autodoc-pytest-fixtures.md b/docs/packages/sphinx-autodoc-pytest-fixtures.md index 011dcc8d..465a09b0 100644 --- a/docs/packages/sphinx-autodoc-pytest-fixtures.md +++ b/docs/packages/sphinx-autodoc-pytest-fixtures.md @@ -50,6 +50,7 @@ pytest_external_fixture_links = { ```{eval-rst} .. autofixtures:: spf_demo_fixtures + :no-index: ``` ### Plugin page helper @@ -91,6 +92,7 @@ directly: |--------|---------|-------------| | `:order:` | `"source"` | `"source"` preserves module order; `"alpha"` sorts alphabetically | | `:exclude:` | (empty) | Comma-separated fixture names to skip | +| `:no-index:` | (off) | Emit descriptions without registering fixtures in the domain index; use when the same module is documented twice on one page | #### autofixture-index options diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py index a8670c12..a0a4d1ba 100644 --- a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib -import pathlib import typing as t from docutils import nodes @@ -108,12 +107,34 @@ def _note_module_dependency(document: nodes.document, module: t.Any) -> None: env.note_dependency(module.__file__) +def _is_native_myst(directive: SphinxDirective) -> bool: + """Return ``True`` when *directive* is invoked natively by MyST-parser. + + MyST-parser passes a ``MockState`` to directives it invokes directly, + whose ``nested_parse`` renders Markdown rather than RST. In that context + the generated ``autofixture`` RST content must be wrapped in an + ``{eval-rst}`` fence so that MyST's nested renderer processes it as RST. + + Directives invoked inside an ``{eval-rst}`` block use a regular docutils + ``Body`` state (RST ``nested_parse``), so no wrapping is needed — + passing a backtick fence there would produce spurious ``[docutils]`` + inline-literal warnings. + """ + try: + from myst_parser.mocking import MockState # noqa: PLC0415 + + return isinstance(directive.state, MockState) + except ImportError: + return False + + def _render_autofixtures_nodes( directive: SphinxDirective, *, modname: str, entries: list[tuple[str, str, t.Any]], order: str = "source", + no_index: bool = False, ) -> list[nodes.Node]: """Render ``autofixture`` directives into doctree nodes. @@ -127,6 +148,10 @@ def _render_autofixtures_nodes( Public fixture entries as ``(attr_name, public_name, fixture_obj)``. order : str, optional ``"source"`` keeps module order; ``"alpha"`` sorts by public name. + no_index : bool, optional + When ``True``, each generated ``autofixture`` directive gets + ``:no-index:`` so the fixtures are described but not registered in + the Sphinx domain index. Returns ------- @@ -139,10 +164,12 @@ def _render_autofixtures_nodes( lines: list[str] = [] for _attr_name, public_name, _value in entries: lines.append(f".. autofixture:: {modname}.{public_name}") + if no_index: + lines.append(" :no-index:") lines.append("") content = "\n".join(lines).strip() - if _is_markdown_source(directive): + if _is_native_myst(directive): content = f"```{{eval-rst}}\n{content}\n```" return directive.parse_text_to_nodes( @@ -151,21 +178,6 @@ def _render_autofixtures_nodes( ) -def _is_markdown_source(directive: SphinxDirective) -> bool: - """Return ``True`` when the current document source is Markdown/MyST.""" - source, _line = directive.get_source_info() - if not source: - source = getattr(directive.state.document, "current_source", "") - if not source: - return False - - return pathlib.Path(source).suffix.lower() in { - ".md", - ".markdown", - ".myst", - } - - class PyFixtureDirective(PyFunction): """Sphinx directive for documenting pytest fixtures: ``.. py:fixture::``. @@ -590,6 +602,7 @@ class AutofixturesDirective(SphinxDirective): option_spec: t.ClassVar[dict[str, t.Any]] = { "order": directives.unchanged, "exclude": directives.unchanged, + "no-index": directives.flag, } def run(self) -> list[nodes.Node]: @@ -618,6 +631,7 @@ def run(self) -> list[nodes.Node]: modname=modname, entries=entries, order=order, + no_index="no-index" in self.options, ) From 4e6e1d666d458445eb13958a5fb7f1ab429436db Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 5 Apr 2026 18:35:57 -0500 Subject: [PATCH 7/7] docs(changes): Note doc-pytest-plugin directive in sphinx-autodoc-pytest-fixtures why: The CHANGES workspace package entry for sphinx-autodoc-pytest-fixtures only described the initial release; the new doc-pytest-plugin directive shipped on this branch needs a release note. what: - Add doc-pytest-plugin summary to sphinx-autodoc-pytest-fixtures entry --- CHANGES | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES b/CHANGES index f8ee46d1..f4919630 100644 --- a/CHANGES +++ b/CHANGES @@ -34,6 +34,11 @@ $ uv add gp-sphinx --prerelease allow badges, classified dependency lists, reverse-dep tracking, and auto-generated usage snippets. Frozen dataclasses for pickle-safe incremental builds, parallel-safe, WCAG AA badge contrast, and pytest 9+ compatible. + New: `doc-pytest-plugin` directive generates a standard pytest plugin page + (install block, `pytest11` autodiscovery note, fixture summary and reference) + from a single directive call. Optional `:project:`, `:summary:`, `:tests-url:`, + and `:install-command:` options; body is free-form. Use `autofixture-index` + + `autofixtures` directly when custom layout is needed. - `sphinx-fonts` — Self-hosted web fonts via Fontsource CDN. Downloads at build time, caches locally, and injects `@font-face` CSS with preload hints and fallback font-metric overrides for zero-CLS loading.