diff --git a/packages/sphinx-autodoc-pytest-fixtures/README.md b/packages/sphinx-autodoc-pytest-fixtures/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml new file mode 100644 index 0000000..4595278 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "sphinx-autodoc-pytest-fixtures" +version = "0.0.1a0" +description = "Sphinx extension for documenting pytest fixtures as first-class objects" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Framework :: Pytest", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Topic :: Software Development :: Testing", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"] +dependencies = [ + "sphinx", + "pytest", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_autodoc_pytest_fixtures"] 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 new file mode 100644 index 0000000..925b359 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/__init__.py @@ -0,0 +1,185 @@ +"""Sphinx extension for documenting pytest fixtures as first-class objects. + +Registers ``py:fixture`` as a domain directive and ``autofixture::`` as an +autodoc documenter. Fixtures are rendered with their scope, user-visible +dependencies, and an auto-generated usage snippet rather than as plain +callable signatures. + +.. note:: + + This extension self-registers its CSS via ``add_css_file()``. The rules + live in ``_static/css/sphinx_autodoc_pytest_fixtures.css`` inside this package. +""" + +from __future__ import annotations + +import logging +import typing as t + +from docutils import nodes +from sphinx.domains import ObjType +from sphinx.domains.python import PythonDomain, PyXRefRole + +# --------------------------------------------------------------------------- +# Re-exports for backward compatibility (tests access these via the package) +# --------------------------------------------------------------------------- +from sphinx_autodoc_pytest_fixtures._badges import ( + _BADGE_TOOLTIPS, + _build_badge_group_node, +) +from sphinx_autodoc_pytest_fixtures._constants import ( + _CONFIG_BUILTIN_LINKS, + _CONFIG_EXTERNAL_LINKS, + _CONFIG_HIDDEN_DEPS, + _CONFIG_LINT_LEVEL, + _EXTENSION_KEY, + _EXTENSION_VERSION, + _STORE_VERSION, + PYTEST_BUILTIN_LINKS, + PYTEST_HIDDEN, + SetupDict, +) +from sphinx_autodoc_pytest_fixtures._css import _CSS +from sphinx_autodoc_pytest_fixtures._detection import ( + _classify_deps, + _get_fixture_fn, + _get_fixture_marker, + _get_return_annotation, + _get_user_deps, + _is_factory, + _is_pytest_fixture, + _iter_injectable_params, +) +from sphinx_autodoc_pytest_fixtures._directives import ( + AutofixtureIndexDirective, + AutofixturesDirective, + PyFixtureDirective, +) +from sphinx_autodoc_pytest_fixtures._documenter import FixtureDocumenter +from sphinx_autodoc_pytest_fixtures._metadata import ( + _build_usage_snippet, + _has_authored_example, + _register_fixture_meta, +) +from sphinx_autodoc_pytest_fixtures._models import ( + FixtureDep, + FixtureMeta, + autofixture_index_node, +) +from sphinx_autodoc_pytest_fixtures._store import ( + _finalize_store, + _get_spf_store, + _on_env_merge_info, + _on_env_purge_doc, + _on_env_updated, +) +from sphinx_autodoc_pytest_fixtures._transforms import ( + _depart_abbreviation_html, + _on_doctree_resolved, + _on_missing_reference, + _visit_abbreviation_html, +) + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logging.getLogger(__name__).addHandler(logging.NullHandler()) + + +def setup(app: Sphinx) -> SetupDict: + """Register the ``sphinx_autodoc_pytest_fixtures`` extension. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + + Returns + ------- + SetupDict + Extension metadata dict. + """ + app.setup_extension("sphinx.ext.autodoc") + + # Register extension CSS so projects adopting this extension get styled + # output without manually copying spf-* rules into their custom.css. + import pathlib + + _static_dir = str(pathlib.Path(__file__).parent / "_static") + + def _add_static_path(app: Sphinx) -> None: + if _static_dir not in app.config.html_static_path: + app.config.html_static_path.append(_static_dir) + + app.connect("builder-inited", _add_static_path) + app.add_css_file("css/sphinx_autodoc_pytest_fixtures.css") + + # Override the built-in abbreviation visitor to emit tabindex when set. + # Sphinx's default visit_abbreviation only passes explanation → title, + # silently dropping all other attributes. This override is a strict + # superset — non-badge abbreviation nodes produce identical output. + app.add_node( + nodes.abbreviation, + override=True, + html=(_visit_abbreviation_html, _depart_abbreviation_html), + ) + + # --- New config values (v1.1) --- + app.add_config_value( + _CONFIG_HIDDEN_DEPS, + default=PYTEST_HIDDEN, + rebuild="env", + types=[frozenset], + ) + app.add_config_value( + _CONFIG_BUILTIN_LINKS, + default=PYTEST_BUILTIN_LINKS, + rebuild="env", + types=[dict], + ) + app.add_config_value( + _CONFIG_EXTERNAL_LINKS, + default={}, + rebuild="env", + types=[dict], + ) + app.add_config_value( + _CONFIG_LINT_LEVEL, + default="warning", + rebuild="env", + types=[str], + ) + + # Register std:fixture so :external+pytest:std:fixture: intersphinx + # references resolve. Pytest registers this in their own conf.py; + # we mirror it so the role is known locally. + app.add_crossref_type("fixture", "fixture") + + # Guard against re-registration when setup() is called multiple times. + if "fixture" not in PythonDomain.object_types: + PythonDomain.object_types["fixture"] = ObjType( + "fixture", + "fixture", + "func", + "obj", + ) + app.add_directive_to_domain("py", "fixture", PyFixtureDirective) + app.add_role_to_domain("py", "fixture", PyXRefRole()) + + app.add_autodocumenter(FixtureDocumenter) + app.add_directive("autofixtures", AutofixturesDirective) + app.add_node(autofixture_index_node) + app.add_directive("autofixture-index", AutofixtureIndexDirective) + + app.connect("missing-reference", _on_missing_reference) + app.connect("doctree-resolved", _on_doctree_resolved) + app.connect("env-purge-doc", _on_env_purge_doc) + app.connect("env-merge-info", _on_env_merge_info) + app.connect("env-updated", _on_env_updated) + + return { + "version": _EXTENSION_VERSION, + "env_version": _STORE_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py new file mode 100644 index 0000000..a3f6143 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_badges.py @@ -0,0 +1,140 @@ +"""Badge group rendering helpers for sphinx_autodoc_pytest_fixtures.""" + +from __future__ import annotations + +from docutils import nodes + +from sphinx_autodoc_pytest_fixtures._constants import _SUPPRESSED_SCOPES +from sphinx_autodoc_pytest_fixtures._css import _CSS + +_BADGE_TOOLTIPS: dict[str, str] = { + "session": "Scope: session \u2014 created once per test session", + "module": "Scope: module \u2014 created once per test module", + "class": "Scope: class \u2014 created once per test class", + "factory": "Factory \u2014 returns a callable that creates instances", + "override_hook": "Override hook \u2014 customize in conftest.py", + "fixture": "pytest fixture \u2014 injected by name into test functions", + "autouse": "Runs automatically for every test (autouse=True)", + "deprecated": "Deprecated \u2014 see docs for replacement", +} + + +def _build_badge_group_node( + scope: str, + kind: str, + autouse: bool, + *, + deprecated: bool = False, + show_fixture_badge: bool = True, +) -> nodes.inline: + """Return a badge group as portable ``nodes.abbreviation`` nodes. + + Each badge renders as ```` in HTML, providing hover + tooltips. Non-HTML builders fall back to plain text. + + Badge slots (left-to-right in visual order): + + * Slot 0 (deprecated): shown when fixture is deprecated + * Slot 1 (scope): shown when ``scope != "function"`` + * Slot 2 (kind): shown for ``"factory"`` / ``"override_hook"``; or + state badge (``"autouse"``) when ``autouse=True`` + * Slot 3 (FIXTURE): shown when ``show_fixture_badge=True`` (default) + + Parameters + ---------- + scope : str + Fixture scope string. + kind : str + Fixture kind string. + autouse : bool + When True, renders AUTO state badge instead of a kind badge. + deprecated : bool + When True, renders a deprecated badge at slot 0 (leftmost). + show_fixture_badge : bool + When False, suppresses the FIXTURE badge at slot 3. Use in contexts + where the fixture type is already implied (e.g. an index table). + + Returns + ------- + nodes.inline + Badge group container with abbreviation badge children. + """ + group = nodes.inline(classes=[_CSS.BADGE_GROUP]) + badges: list[nodes.abbreviation] = [] + + # Slot 0 — deprecated badge (leftmost when present) + if deprecated: + badges.append( + nodes.abbreviation( + "deprecated", + "deprecated", + explanation=_BADGE_TOOLTIPS["deprecated"], + classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.DEPRECATED], + ) + ) + + # Slot 1 — scope badge (only non-function scope) + if scope and scope not in _SUPPRESSED_SCOPES: + badges.append( + nodes.abbreviation( + scope, + scope, + explanation=_BADGE_TOOLTIPS.get(scope, f"Scope: {scope}"), + classes=[_CSS.BADGE, _CSS.BADGE_SCOPE, _CSS.scope(scope)], + ) + ) + + # Slot 2 — kind or autouse badge + if autouse: + badges.append( + nodes.abbreviation( + "auto", + "auto", + explanation=_BADGE_TOOLTIPS["autouse"], + classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.AUTOUSE], + ) + ) + elif kind == "factory": + badges.append( + nodes.abbreviation( + "factory", + "factory", + explanation=_BADGE_TOOLTIPS["factory"], + classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.FACTORY], + ) + ) + elif kind == "override_hook": + badges.append( + nodes.abbreviation( + "override", + "override", + explanation=_BADGE_TOOLTIPS["override_hook"], + classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.OVERRIDE], + ) + ) + + # Slot 3 — fixture badge (rightmost, suppressed in index table context) + if show_fixture_badge: + badges.append( + nodes.abbreviation( + "fixture", + "fixture", + explanation=_BADGE_TOOLTIPS["fixture"], + classes=[_CSS.BADGE, _CSS.BADGE_FIXTURE], + ) + ) + + # Make badges focusable for touch/keyboard tooltip accessibility. + # Sphinx's built-in visit_abbreviation does NOT emit tabindex — our + # custom visitor override (_visit_abbreviation_html) handles it. + for badge in badges: + badge["tabindex"] = "0" + + # Interleave with text separators for non-HTML builders (CSS gap + # handles spacing in HTML; text/LaTeX/man builders need explicit spaces). + for i, badge in enumerate(badges): + group += badge + if i < len(badges) - 1: + group += nodes.Text(" ") + + return group diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_constants.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_constants.py new file mode 100644 index 0000000..94190da --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_constants.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import re +import typing as t + + +class SetupDict(t.TypedDict): + """Return type for Sphinx extension setup().""" + + version: str + env_version: int + parallel_read_safe: bool + parallel_write_safe: bool + + +# --------------------------------------------------------------------------- +# Extension identity and version +# --------------------------------------------------------------------------- + +_EXTENSION_KEY = "sphinx_autodoc_pytest_fixtures" +"""Domaindata namespace key used in ``env.domaindata``.""" + +_EXTENSION_VERSION = "1.0" +"""Reported in ``setup()`` return dict.""" + +# --------------------------------------------------------------------------- +# Default values +# --------------------------------------------------------------------------- + +_DEFAULTS: dict[str, str] = { + "scope": "function", + "kind": "resource", + "usage": "auto", +} + +# --------------------------------------------------------------------------- +# Field labels for rendered metadata +# --------------------------------------------------------------------------- + +_FIELD_LABELS: dict[str, str] = { + "scope": "Scope", + "depends": "Depends on", + "autouse": "Autouse", + "kind": "Kind", + "used_by": "Used by", + "parametrized": "Parametrized", +} + +# --------------------------------------------------------------------------- +# Callout messages for fixture cards +# --------------------------------------------------------------------------- + +_CALLOUT_MESSAGES: dict[str, str] = { + "autouse": ( + "No request needed \u2014 this fixture runs automatically for every test." + ), + "session_scope": ( + "Created once per test session and shared across all tests. " + "Requesting this fixture does not create a new instance per test." + ), + "override_hook": ( + "This is an override hook. Override it in your project\u2019s " + "conftest.py to customise behaviour for your test suite." + ), + "yield_fixture": ( + "This is a yield fixture \u2014 it runs setup code before yielding " + "the value to the test, then teardown code after the test completes." + ), + "async_fixture": "This is an async fixture. Use it in async test functions.", +} + +# --------------------------------------------------------------------------- +# Fixture index table structure +# --------------------------------------------------------------------------- + +_INDEX_TABLE_COLUMNS: tuple[tuple[str, int], ...] = ( + ("Fixture", 20), + ("Flags", 22), + ("Returns", 12), + ("Description", 46), +) + +# --------------------------------------------------------------------------- +# Config attribute names (registered via app.add_config_value) +# --------------------------------------------------------------------------- + +_CONFIG_HIDDEN_DEPS = "pytest_fixture_hidden_dependencies" +_CONFIG_BUILTIN_LINKS = "pytest_fixture_builtin_links" +_CONFIG_EXTERNAL_LINKS = "pytest_external_fixture_links" +_CONFIG_LINT_LEVEL = "pytest_fixture_lint_level" + +# --------------------------------------------------------------------------- +# Intersphinx resolution keys +# --------------------------------------------------------------------------- + +_INTERSPHINX_PROJECT = "pytest" +_INTERSPHINX_FIXTURE_ROLE = "std:fixture" + +# --------------------------------------------------------------------------- +# Scopes that suppress the scope badge (function scope = no badge) +# --------------------------------------------------------------------------- + +_SUPPRESSED_SCOPES: frozenset[str] = frozenset({"function"}) + +# --------------------------------------------------------------------------- +# Compiled regex patterns +# --------------------------------------------------------------------------- + +_RST_INLINE_PATTERN = re.compile( + r":(\w+):`([^`]+)`" # :role:`content` + r"|``([^`]+)``" # ``literal`` + r"|`([^`]+)`" # `interpreted text` +) +_IDENTIFIER_PATTERN = re.compile(r"(\b[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*\b)") + +# --------------------------------------------------------------------------- +# Fixture metadata models — env-safe (all fields are pickle-safe primitives) +# --------------------------------------------------------------------------- + +FixtureKind = t.Literal["resource", "factory", "override_hook"] +_KNOWN_KINDS: frozenset[str] = frozenset(t.get_args(FixtureKind)) + +_STORE_VERSION = 5 +"""Bump whenever ``FixtureMeta`` or the store schema changes. + +Used both as the Sphinx ``env_version`` (triggers full cache invalidation) and +as a runtime sentinel inside the store dict (guards against stale pickles on +incremental builds when ``env_version`` was not bumped). +""" + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Fixtures hidden from "Depends on" entirely (low-value noise for readers). +# Does NOT include fixtures that have entries in PYTEST_BUILTIN_LINKS — +# those are shown with external hyperlinks instead of being hidden. +PYTEST_HIDDEN: frozenset[str] = frozenset( + { + "pytestconfig", + "capfd", + "capsysbinary", + "capfdbinary", + "recwarn", + "tmpdir", + "pytester", + "testdir", + "record_property", + "record_xml_attribute", + "record_testsuite_property", + "cache", + }, +) + +# External links for pytest built-in fixtures shown in "Depends on" blocks. +# Used as offline fallback when intersphinx inventory is unavailable. +PYTEST_BUILTIN_LINKS: dict[str, str] = { + "tmp_path_factory": ( + "https://docs.pytest.org/en/stable/reference/fixtures.html#tmp_path_factory" + ), + "tmp_path": "https://docs.pytest.org/en/stable/reference/fixtures.html#tmp_path", + "monkeypatch": ( + "https://docs.pytest.org/en/stable/reference/fixtures.html#monkeypatch" + ), + "request": "https://docs.pytest.org/en/stable/reference/fixtures.html#request", + "capsys": "https://docs.pytest.org/en/stable/reference/fixtures.html#capsys", + "caplog": "https://docs.pytest.org/en/stable/reference/fixtures.html#caplog", +} diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_css.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_css.py new file mode 100644 index 0000000..b24f781 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_css.py @@ -0,0 +1,28 @@ +from __future__ import annotations + + +class _CSS: + """CSS class name constants used in generated HTML. + + Centralises every ``spf-*`` class name so the extension and stylesheet + stay in sync. Tests import this class to assert on rendered output. + """ + + PREFIX = "spf" + BADGE_GROUP = f"{PREFIX}-badge-group" + BADGE = f"{PREFIX}-badge" + BADGE_SCOPE = f"{PREFIX}-badge--scope" + BADGE_KIND = f"{PREFIX}-badge--kind" + BADGE_STATE = f"{PREFIX}-badge--state" + BADGE_FIXTURE = f"{PREFIX}-badge--fixture" + FACTORY = f"{PREFIX}-factory" + OVERRIDE = f"{PREFIX}-override" + AUTOUSE = f"{PREFIX}-autouse" + DEPRECATED = f"{PREFIX}-deprecated" + FIXTURE_INDEX = f"{PREFIX}-fixture-index" + TABLE_SCROLL = f"{PREFIX}-table-scroll" + + @staticmethod + def scope(name: str) -> str: + """Return the scope-specific CSS class, e.g. ``spf-scope-session``.""" + return f"{_CSS.PREFIX}-scope-{name}" diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_detection.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_detection.py new file mode 100644 index 0000000..079b82d --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_detection.py @@ -0,0 +1,348 @@ +"""Fixture detection and classification helpers for sphinx_autodoc_pytest_fixtures.""" + +from __future__ import annotations + +import collections.abc +import inspect +import typing as t + +from sphinx.util import logging as sphinx_logging +from sphinx.util.typing import stringify_annotation + +from sphinx_autodoc_pytest_fixtures._constants import ( + _CONFIG_BUILTIN_LINKS, + _CONFIG_EXTERNAL_LINKS, + _CONFIG_HIDDEN_DEPS, + _DEFAULTS, + PYTEST_BUILTIN_LINKS, + PYTEST_HIDDEN, +) +from sphinx_autodoc_pytest_fixtures._models import ( + _FixtureFunctionDefinitionAdapter, + _FixtureMarker, +) + +if t.TYPE_CHECKING: + pass + +logger = sphinx_logging.getLogger(__name__) + + +def _is_pytest_fixture(obj: t.Any) -> bool: + """Return True if *obj* is a pytest fixture callable. + + Parameters + ---------- + obj : Any + The object to inspect. + + Returns + ------- + bool + True for pytest 9+ ``FixtureFunctionDefinition`` instances and older + pytest fixtures marked with ``_fixture_function_marker``. + """ + try: + from _pytest.fixtures import FixtureFunctionDefinition + + if isinstance(obj, FixtureFunctionDefinition): + return True + except ImportError: + pass + return hasattr(obj, "_fixture_function_marker") + + +def _get_fixture_fn(obj: t.Any) -> t.Callable[..., t.Any]: + """Return the raw underlying function from a fixture wrapper. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + + Returns + ------- + Callable + The unwrapped fixture function with original annotations and docstring. + """ + if hasattr(obj, "_get_wrapped_function"): + return obj._get_wrapped_function() # type: ignore[no-any-return] + if hasattr(obj, "_fixture_function"): + return obj._fixture_function # type: ignore[no-any-return] + if hasattr(obj, "__wrapped__"): + return obj.__wrapped__ # type: ignore[no-any-return] + return t.cast("t.Callable[..., t.Any]", obj) + + +def _get_fixture_marker(obj: t.Any) -> _FixtureMarker: + """Return normalised fixture metadata for *obj*. + + Handles pytest 9+ FixtureFunctionDefinition (scope is Scope enum) and + older pytest fixtures (_fixture_function_marker attribute). + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + + Returns + ------- + _FixtureMarker + Normalised marker object exposing ``scope`` (always a str), + ``autouse``, ``params``, and ``name``. + """ + try: + from _pytest.fixtures import FixtureFunctionDefinition + + if isinstance(obj, FixtureFunctionDefinition): + # FixtureFunctionDefinition wraps a FixtureFunctionMarker; + # access the marker to get scope/autouse/params/name. + marker = obj._fixture_function_marker + return _FixtureFunctionDefinitionAdapter(marker) + except ImportError: + pass + old_marker = getattr(obj, "_fixture_function_marker", None) + if old_marker is not None: + return _FixtureFunctionDefinitionAdapter(old_marker) + msg = f"pytest fixture marker metadata not found on {type(obj).__name__!r}" + raise AttributeError(msg) + + +def _iter_injectable_params( + obj: t.Any, +) -> t.Iterator[tuple[str, inspect.Parameter]]: + """Yield (name, param) for injectable (non-variadic) fixture parameters. + + Pytest injects all POSITIONAL_OR_KEYWORD and KEYWORD_ONLY params by name. + POSITIONAL_ONLY parameters (before ``/``) cannot be injected by name — skip. + VAR_POSITIONAL (*args) and VAR_KEYWORD (**kwargs) are also skipped. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + + Yields + ------ + tuple[str, inspect.Parameter] + ``(name, param)`` pairs for injectable fixture parameters only. + """ + sig = inspect.signature(_get_fixture_fn(obj)) + for name, param in sig.parameters.items(): + if param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + continue + yield name, param + + +def _get_user_deps( + obj: t.Any, + hidden: frozenset[str] | None = None, +) -> list[tuple[str, t.Any]]: + """Return ``(name, annotation)`` pairs for user-visible fixture dependencies. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + hidden : frozenset[str] | None + Names to exclude from the dependency list. When ``None``, falls back + to the module-level :data:`PYTEST_HIDDEN` constant. + + Returns + ------- + list[tuple[str, Any]] + Parameters of the wrapped function that are not pytest built-in fixtures. + These are the fixtures users need to provide (or that are auto-provided + by other project fixtures). + """ + if hidden is None: + hidden = PYTEST_HIDDEN + return [ + (name, param.annotation) + for name, param in _iter_injectable_params(obj) + if name not in hidden + ] + + +def _classify_deps( + obj: t.Any, + app: t.Any, +) -> tuple[list[str], dict[str, str], list[str]]: + """Classify fixture dependencies into three buckets. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + app : Any + The Sphinx application (may be ``None`` in unit-test contexts). + + Returns + ------- + tuple[list[str], dict[str, str], list[str]] + ``(project_deps, builtin_deps, hidden_deps)`` where: + + * *project_deps* — dep names to render as ``:fixture:`` cross-refs + * *builtin_deps* — dict mapping dep name → external URL + * *hidden_deps* — dep names suppressed entirely + """ + if app is not None: + hidden: frozenset[str] = getattr( + app.config, + _CONFIG_HIDDEN_DEPS, + PYTEST_HIDDEN, + ) + builtin_links: dict[str, str] = getattr( + app.config, + _CONFIG_BUILTIN_LINKS, + PYTEST_BUILTIN_LINKS, + ) + external_links: dict[str, str] = getattr( + app.config, + _CONFIG_EXTERNAL_LINKS, + {}, + ) + all_links = {**builtin_links, **external_links} + else: + hidden = PYTEST_HIDDEN + all_links = PYTEST_BUILTIN_LINKS + + project: list[str] = [] + builtin: dict[str, str] = {} + hidden_list: list[str] = [] + + for name, _param in _iter_injectable_params(obj): + if name in hidden: + hidden_list.append(name) + elif name in all_links: + builtin[name] = all_links[name] + else: + project.append(name) + + return project, builtin, hidden_list + + +def _get_return_annotation(obj: t.Any) -> t.Any: + """Return the injected type of the fixture's underlying function. + + For ``yield`` fixtures annotated as ``Generator[T, None, None]`` or + ``Iterator[T]``, returns the yield type ``T`` — the value the test function + actually receives. This matches how pytest users think about the fixture's + return contract. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + + Returns + ------- + Any + The resolved return/yield type annotation, or ``inspect.Parameter.empty`` + when the annotation cannot be resolved (e.g. forward references under + ``TYPE_CHECKING`` guards not importable at doc-build time). + """ + fn = _get_fixture_fn(obj) + try: + hints = t.get_type_hints(fn) + except (NameError, AttributeError, TypeError, RecursionError): + # Forward references (TYPE_CHECKING guards), parameterized generics + # (TypeError in some Python versions), circular imports (RecursionError), + # or other resolution failures. Fall back to the raw annotation string. + ann = inspect.signature(fn).return_annotation + return inspect.Parameter.empty if ann is inspect.Parameter.empty else ann + ret = hints.get("return", inspect.Parameter.empty) + if ret is inspect.Parameter.empty: + return ret + # Unwrap Generator/Iterator and their async counterparts so that + # yield-based fixtures show the injected type, not the generator type. + origin = t.get_origin(ret) + if origin in ( + collections.abc.Generator, + collections.abc.Iterator, + collections.abc.AsyncGenerator, + collections.abc.AsyncIterator, + ): + args = t.get_args(ret) + return args[0] if args else inspect.Parameter.empty + return ret + + +def _format_type_short(annotation: t.Any) -> str: + """Format *annotation* to a short display string for docs. + + Parameters + ---------- + annotation : Any + A type annotation, possibly ``inspect.Parameter.empty``. + + Returns + ------- + str + A human-readable type string, or ``"..."`` when annotation is absent. + """ + if annotation is inspect.Parameter.empty: + return "..." + try: + return stringify_annotation(annotation) + except Exception: + return str(annotation) + + +def _is_factory(obj: t.Any) -> bool: + """Return True if *obj* is a factory fixture. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + + Returns + ------- + bool + True when the return annotation is ``type[X]`` or ``Callable[..., X]``. + Returns False when no annotation (or ``t.Any``) is present — use + the explicit ``:kind: factory`` option to override. + """ + ret = _get_return_annotation(obj) + # t.Any / unannotated: no type information — default to resource. + if ret is inspect.Parameter.empty or ret is t.Any: + return False + origin = t.get_origin(ret) + if origin is type or origin is collections.abc.Callable: + return True + ret_str = str(ret) + return ret_str.startswith("type[") or "Callable" in ret_str + + +def _infer_kind(obj: t.Any, explicit_kind: str | None = None) -> str: + """Return the fixture kind, honouring an explicit override. + + Priority chain: + + 1. *explicit_kind* — set via ``:kind:`` directive option by the author. + 2. Type annotation — ``type[X]`` / ``Callable`` → ``"factory"``. + 3. Default → ``"resource"``. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + explicit_kind : str | None + Value from the ``:kind:`` directive option, if provided. + + Returns + ------- + str + One of ``"resource"``, ``"factory"``, or ``"override_hook"`` (or any + custom string passed via ``:kind:``). + """ + if explicit_kind: + return explicit_kind + if _is_factory(obj): + return "factory" + return str(_DEFAULTS["kind"]) 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 new file mode 100644 index 0000000..42d212a --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_directives.py @@ -0,0 +1,575 @@ +"""Sphinx directive classes for sphinx_autodoc_pytest_fixtures.""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from docutils.parsers.rst import Directive, directives +from docutils.statemachine import ViewList +from sphinx import addnodes +from sphinx.domains.python import PyFunction +from sphinx.util import logging as sphinx_logging +from sphinx.util.docfields import Field, GroupedField +from sphinx.util.docutils import SphinxDirective + +from sphinx_autodoc_pytest_fixtures._constants import ( + _CALLOUT_MESSAGES, + _CONFIG_BUILTIN_LINKS, + _CONFIG_EXTERNAL_LINKS, + _DEFAULTS, + _FIELD_LABELS, + _KNOWN_KINDS, + PYTEST_BUILTIN_LINKS, +) +from sphinx_autodoc_pytest_fixtures._css import _CSS +from sphinx_autodoc_pytest_fixtures._detection import ( + _get_fixture_fn, + _get_fixture_marker, + _is_pytest_fixture, +) +from sphinx_autodoc_pytest_fixtures._metadata import ( + _build_usage_snippet, + _has_authored_example, + _summary_insert_index, +) +from sphinx_autodoc_pytest_fixtures._models import ( + FixtureDep, + FixtureMeta, + autofixture_index_node, +) +from sphinx_autodoc_pytest_fixtures._store import _get_spf_store, _resolve_builtin_url + +if t.TYPE_CHECKING: + pass + +logger = sphinx_logging.getLogger(__name__) + + +class PyFixtureDirective(PyFunction): + """Sphinx directive for documenting pytest fixtures: ``.. py:fixture::``. + + Registered as ``fixture`` in the Python domain. Renders as:: + + fixture server -> Server + + instead of:: + + server(request, monkeypatch, config_file) -> Server + """ + + option_spec = PyFunction.option_spec.copy() + option_spec.update( + { + "scope": directives.unchanged, + "autouse": directives.flag, + "depends": directives.unchanged, + "factory": directives.flag, + "overridable": directives.flag, + "kind": directives.unchanged, # explicit kind override + "return-type": directives.unchanged, + "usage": directives.unchanged, # "auto" (default) or "none" + "params": directives.unchanged, # e.g. ":params: val1, val2" + "teardown": directives.flag, # ":teardown:" flag for yield fixtures + "async": directives.flag, # ":async:" flag for async fixtures + "deprecated": directives.unchanged, # version string + "replacement": directives.unchanged, # canonical replacement fixture + "teardown-summary": directives.unchanged, # teardown description + }, + ) + + doc_field_types = [ # noqa: RUF012 + Field( + "scope", + label=_FIELD_LABELS["scope"], + has_arg=False, + names=("scope",), + ), + GroupedField( + "depends", + label=_FIELD_LABELS["depends"], + rolename="fixture", + names=("depends", "depend"), + can_collapse=True, + ), + Field( + "factory", + label="Factory", + has_arg=False, + names=("factory",), + ), + Field( + "overridable", + label="Override hook", + has_arg=False, + names=("overridable",), + ), + ] + + def needs_arglist(self) -> bool: + """Suppress ``()`` — fixtures are not called with arguments.""" + return False + + def get_signature_prefix( + self, + sig: str, + ) -> t.Sequence[addnodes.desc_sig_element]: + """Render the ``fixture`` keyword before the fixture name. + + Parameters + ---------- + sig : str + The raw signature string from the directive. + + Returns + ------- + Sequence[addnodes.desc_sig_element] + Prefix nodes rendering as ``fixture `` before the fixture name. + """ + return [ + addnodes.desc_sig_keyword("", "fixture"), + addnodes.desc_sig_space(), + ] + + def handle_signature( + self, + sig: str, + signode: addnodes.desc_signature, + ) -> tuple[str, str]: + """Store fixture metadata on signode for badge injection. + + Parameters + ---------- + sig : str + The raw signature string from the directive. + signode : addnodes.desc_signature + The signature node to annotate. + + Returns + ------- + tuple[str, str] + ``(fullname, prefix)`` from the parent implementation. + """ + result = super().handle_signature(sig, signode) + signode["spf_scope"] = self.options.get("scope", _DEFAULTS["scope"]) + signode["spf_kind"] = self.options.get("kind", _DEFAULTS["kind"]) + signode["spf_autouse"] = "autouse" in self.options + signode["spf_deprecated"] = "deprecated" in self.options + signode["spf_ret_type"] = self.options.get("return-type", "") + return result + + def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str: + """Return index entry text for the fixture. + + Parameters + ---------- + modname : str + The module name containing the fixture. + name_cls : tuple[str, str] + ``(fullname, classname_prefix)`` from ``handle_signature``. + + Returns + ------- + str + Index entry in the form ``name (pytest fixture in modname)``. + """ + name, _cls = name_cls + return f"{name} (pytest fixture in {modname})" + + def transform_content( + self, + content_node: addnodes.desc_content, + ) -> None: + """Inject fixture metadata as doctree nodes before DocFieldTransformer. + + ``transform_content`` runs at line 108 of ``ObjectDescription.run()``; + ``DocFieldTransformer.transform_all()`` runs at line 112 — so + ``nodes.field_list`` entries inserted here ARE processed by + ``DocFieldTransformer`` and receive full field styling. + + Parameters + ---------- + content_node : addnodes.desc_content + The content node to prepend metadata into. + """ + scope = self.options.get("scope", _DEFAULTS["scope"]) + depends_str = self.options.get("depends", "") + ret_type = self.options.get("return-type", "") + show_usage = self.options.get("usage", _DEFAULTS["usage"]) != "none" + kind = self.options.get("kind", "") + autouse = "autouse" in self.options + has_teardown = "teardown" in self.options + is_async = "async" in self.options + + field_list = nodes.field_list() + + # Scope field removed — badges communicate scope at a glance, + # the index table provides comparison. See P2-2 in the enhancement spec. + + # --- Autouse field --- + if autouse: + field_list += nodes.field( + "", + nodes.field_name("", _FIELD_LABELS["autouse"]), + nodes.field_body( + "", + nodes.paragraph("", "yes \u2014 runs automatically for every test"), + ), + ) + + # --- Kind field (only for custom/nonstandard kinds not covered by badges) --- + if kind and kind not in _KNOWN_KINDS: + field_list += nodes.field( + "", + nodes.field_name("", _FIELD_LABELS["kind"]), + nodes.field_body("", nodes.paragraph("", kind)), + ) + + # --- Depends-on fields — project deps as :fixture: xrefs, + # builtin/external deps as external hyperlinks --- + if depends_str: + # Resolve builtin/external link mapping from config + app_obj = getattr(getattr(self, "env", None), "app", None) + builtin_links: dict[str, str] = ( + getattr( + app_obj.config, + _CONFIG_BUILTIN_LINKS, + PYTEST_BUILTIN_LINKS, + ) + if app_obj is not None + else PYTEST_BUILTIN_LINKS + ) + external_links: dict[str, str] = ( + getattr(app_obj.config, _CONFIG_EXTERNAL_LINKS, {}) + if app_obj is not None + else {} + ) + all_links = {**builtin_links, **external_links} + + # Collect all dep nodes, then emit one comma-separated row + # (matches the "Used by" pattern in _on_doctree_resolved). + dep_ref_nodes: list[nodes.Node] = [] + for dep in (d.strip() for d in depends_str.split(",") if d.strip()): + # Resolve URL: intersphinx → config → hardcoded fallback + url: str | None = None + if dep in all_links: + url = _resolve_builtin_url(dep, app_obj) or all_links[dep] + if url: + dep_ref_nodes.append( + nodes.reference(dep, "", nodes.literal(dep, dep), refuri=url) + ) + else: + ref_ns, _ = self.state.inline_text( + f":fixture:`{dep}`", + self.lineno, + ) + dep_ref_nodes.extend(ref_ns) + + if dep_ref_nodes: + body_para = nodes.paragraph() + for i, dn in enumerate(dep_ref_nodes): + body_para += dn + if i < len(dep_ref_nodes) - 1: + body_para += nodes.Text(", ") + field_list += nodes.field( + "", + nodes.field_name("", _FIELD_LABELS["depends"]), + nodes.field_body("", body_para), + ) + + # --- Deprecation warning (before lifecycle callouts) --- + deprecated_version = self.options.get("deprecated") + replacement_name = self.options.get("replacement") + + if deprecated_version is not None: + warning = nodes.warning() + dep_para = nodes.paragraph() + dep_para += nodes.Text(f"Deprecated since version {deprecated_version}.") + if replacement_name: + dep_para += nodes.Text(" Use ") + ref_ns, _ = self.state.inline_text( + f":fixture:`{replacement_name}`", + self.lineno, + ) + dep_para.extend(ref_ns) + dep_para += nodes.Text(" instead.") + warning += dep_para + # Add spf-deprecated class to the parent desc node for CSS muting + for parent in self.state.document.findall(addnodes.desc): + for sig in parent.findall(addnodes.desc_signature): + if sig.get("spf_deprecated"): + if _CSS.DEPRECATED not in parent["classes"]: + parent["classes"].append(_CSS.DEPRECATED) + break + + # --- Lifecycle callouts (session note + override hook tip) --- + callout_nodes: list[nodes.Node] = [] + + if deprecated_version is not None: + callout_nodes.append(warning) + + if scope == "session": + note = nodes.note() + note += nodes.paragraph("", _CALLOUT_MESSAGES["session_scope"]) + callout_nodes.append(note) + + if kind == "override_hook": + tip = nodes.tip() + tip += nodes.paragraph("", _CALLOUT_MESSAGES["override_hook"]) + callout_nodes.append(tip) + + if has_teardown: + note = nodes.note() + note += nodes.paragraph("", _CALLOUT_MESSAGES["yield_fixture"]) + teardown_text = self.options.get("teardown-summary", "") + if teardown_text: + note += nodes.paragraph( + "", + "", + nodes.strong("", "Teardown: "), + nodes.Text(teardown_text), + ) + callout_nodes.append(note) + + if is_async: + note = nodes.note() + note += nodes.paragraph("", _CALLOUT_MESSAGES["async_fixture"]) + callout_nodes.append(note) + + # --- Usage snippet (five-zone insertion after first paragraph) --- + raw_arg = self.arguments[0] if self.arguments else "" + fixture_name = raw_arg.split("(")[0].strip() + + snippet: nodes.Node | None = None + if show_usage and fixture_name and not _has_authored_example(content_node): + snippet = _build_usage_snippet( + fixture_name, + ret_type or None, + kind or _DEFAULTS["kind"], + scope, + autouse, + ) + + # Collect generated nodes and insert in five-zone order after summary. + # Insertion uses reversed() so nodes end up in forward order. + generated: list[nodes.Node] = [*callout_nodes] + if field_list.children: + generated.append(field_list) + if snippet is not None: + generated.append(snippet) + + if generated: + insert_idx = _summary_insert_index(content_node) + for node in reversed(generated): + content_node.insert(insert_idx, node) + + def add_target_and_index( + self, + name_cls: tuple[str, str], + sig: str, + signode: addnodes.desc_signature, + ) -> None: + """Register the fixture target and index entry. + + Notes + ----- + Bypasses ``PyFunction.add_target_and_index``, which always appends a + ``name() (in module X)`` index entry — wrong for fixtures. Calls + ``PyObject.add_target_and_index`` directly so only the fixture-style + ``get_index_text`` entry is produced. + + Stores ``spf_canonical_name`` on *signode* for metadata-driven + rendering in :func:`_on_doctree_resolved`. + """ + modname = self.options.get("module", self.env.ref_context.get("py:module", "")) + name = name_cls[0] + canonical = f"{modname}.{name}" if modname else name + signode["spf_canonical_name"] = canonical + super(PyFunction, self).add_target_and_index(name_cls, sig, signode) + + # Scope/kind-qualified pair index entries for the general index. + node_id = signode.get("ids", [""])[0] if signode.get("ids") else "" + scope = self.options.get("scope", _DEFAULTS["scope"]) + kind = self.options.get("kind", _DEFAULTS["kind"]) + if scope != "function" and node_id: + self.indexnode["entries"].append( + ("pair", f"{scope}-scoped fixtures; {name}", node_id, "", None) + ) + if kind not in ("resource",) and node_id: + kind_label = { + "factory": "factory fixtures", + "override_hook": "override hooks", + }.get(kind, f"{kind} fixtures") + self.indexnode["entries"].append( + ("pair", f"{kind_label}; {name}", node_id, "", None) + ) + + # Register minimal FixtureMeta for manual directives so they + # participate in short-name xrefs, "Used by", and reverse_deps. + # Guard: don't overwrite richer autodoc-generated metadata. + store = _get_spf_store(self.env) + if canonical not in store["fixtures"]: + public = canonical.rsplit(".", 1)[-1] + deps: list[FixtureDep] = [] + if depends_str := self.options.get("depends"): + deps.extend( + FixtureDep(display_name=d.strip(), kind="fixture") + for d in depends_str.split(",") + if d.strip() + ) + store["fixtures"][canonical] = FixtureMeta( + docname=self.env.docname, + canonical_name=canonical, + public_name=public, + source_name=public, + scope=self.options.get("scope", _DEFAULTS["scope"]), + autouse="autouse" in self.options, + kind=self.options.get("kind", _DEFAULTS["kind"]), + return_display=self.options.get("return-type", ""), + return_xref_target=None, + deps=tuple(deps), + param_reprs=tuple( + p.strip() + for p in self.options.get("params", "").split(",") + if p.strip() + ), + has_teardown="teardown" in self.options, + is_async="async" in self.options, + summary="", + deprecated=self.options.get("deprecated"), + replacement=self.options.get("replacement"), + teardown_summary=self.options.get("teardown-summary"), + ) + + +class AutofixturesDirective(Directive): + """Bulk fixture autodoc directive: ``.. autofixtures:: module.name``. + + Scans *module.name* for all pytest fixtures and emits one + ``.. autofixture::`` directive per fixture found. This eliminates + the need to list every fixture manually in docs. + + Usage:: + + .. autofixtures:: libtmux.pytest_plugin + :order: source + :exclude: clear_env + + Options + ------- + order : str, optional + ``"source"`` (default) preserves module attribute order. + ``"alpha"`` sorts fixtures alphabetically by public name. + exclude : str, optional + Comma-separated list of fixture public names to skip. + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "order": directives.unchanged, + "exclude": directives.unchanged, + } + + 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", "") + excluded: set[str] = { + name.strip() for name in exclude_str.split(",") if name.strip() + } + + try: + module = importlib.import_module(modname) + except ImportError: + logger.warning( + "autofixtures: cannot import module %r — skipping.", + modname, + ) + 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, + ) + return container.children + + +class AutofixtureIndexDirective(SphinxDirective): + """Generate a fixture index table from the :class:`FixtureStoreDict`. + + Emits a :class:`autofixture_index_node` placeholder at parse time. + The placeholder is resolved into a ``nodes.table`` during + ``doctree-resolved``, when the store has been finalized by ``env-updated``. + + Usage:: + + .. autofixture-index:: libtmux.pytest_plugin + :exclude: _internal_helper + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "exclude": directives.unchanged, + } + + def run(self) -> list[nodes.Node]: + """Return a placeholder node with module and exclude metadata.""" + node = autofixture_index_node() + node["module"] = self.arguments[0].strip() + node["exclude"] = { + s.strip() for s in self.options.get("exclude", "").split(",") if s.strip() + } + return [node] diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_documenter.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_documenter.py new file mode 100644 index 0000000..67d78a6 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_documenter.py @@ -0,0 +1,280 @@ +"""Autodoc documenter for pytest fixtures.""" + +from __future__ import annotations + +import inspect +import typing as t + +from docutils.parsers.rst import directives +from sphinx.ext.autodoc import FunctionDocumenter +from sphinx.util import logging as sphinx_logging + +from sphinx_autodoc_pytest_fixtures._constants import ( + _CONFIG_HIDDEN_DEPS, + PYTEST_HIDDEN, +) +from sphinx_autodoc_pytest_fixtures._detection import ( + _format_type_short, + _get_fixture_fn, + _get_fixture_marker, + _get_return_annotation, + _get_user_deps, + _infer_kind, + _is_pytest_fixture, +) +from sphinx_autodoc_pytest_fixtures._metadata import ( + _extract_teardown_summary, + _register_fixture_meta, +) + +if t.TYPE_CHECKING: + pass + +logger = sphinx_logging.getLogger(__name__) + + +class FixtureDocumenter(FunctionDocumenter): + """Autodoc documenter for pytest fixtures. + + Registered via ``app.add_autodocumenter()``. Enables:: + + .. autofixture:: libtmux.pytest_plugin.server + :kind: override_hook + """ + + objtype = "fixture" + directivetype = "fixture" + priority = FunctionDocumenter.priority + 10 + + option_spec: t.ClassVar[dict[str, t.Any]] = { + **FunctionDocumenter.option_spec, + "kind": directives.unchanged, + } + + # Resolved during import_object(); None until then. + _fixture_public_name: str | None = None + + @classmethod + def can_document_member( + cls, + member: t.Any, + membername: str, + isattr: bool, + parent: t.Any, + ) -> bool: + """Return True if *member* is a pytest fixture.""" + return bool(_is_pytest_fixture(member)) + + def import_object(self, raiseerror: bool = False) -> bool: + """Import the fixture object, with alias-aware fallback. + + When ``@pytest.fixture(name='alias')`` is used, the module attribute + name differs from the public fixture name. ``autofixture::`` directives + may be written with either the attribute name or the public alias. The + standard ``super().import_object()`` path finds the attribute name; if + that fails we scan the module members looking for a fixture whose public + name matches the requested name. + + Parameters + ---------- + raiseerror : bool + When True, raise ``ImportError`` on failure instead of returning + False. + + Returns + ------- + bool + True when the fixture object was resolved successfully. + """ + import importlib + + # --- Standard path: resolve by module attribute name --- + if super().import_object(raiseerror=False): + try: + marker = _get_fixture_marker(self.object) + self._fixture_public_name = ( + marker.name or _get_fixture_fn(self.object).__name__ + ) + except AttributeError: + pass + return True + + # --- Alias fallback: scan module members --- + modname, _, wanted_public = self.fullname.rpartition(".") + if not modname: + if raiseerror: + msg = f"fixture {self.fullname!r} not found" + raise ImportError(msg) + return False + + try: + module = importlib.import_module(modname) + except ImportError: + if raiseerror: + raise + return False + + found: list[tuple[str, t.Any, str]] = [] + 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 = marker.name or _get_fixture_fn(value).__name__ + if public == wanted_public: + found.append((attr_name, value, public)) + + if len(found) > 1: + logger.warning( + "autofixture: multiple fixtures with public name %r in %s; " + "using first match. Use the attribute name to disambiguate.", + wanted_public, + modname, + ) + + if found: + attr_name, value, public_name = found[0] + self.object = value + self.modname = modname + self.objpath = [attr_name] # real attr path for source lookup + self.fullname = f"{modname}.{public_name}" + self._fixture_public_name = public_name + self.parent = module + return True + + if raiseerror: + msg = f"fixture alias {self.fullname!r} not found" + raise ImportError(msg) + return False + + def format_name(self) -> str: + """Return the effective fixture name, honouring ``@pytest.fixture(name=...)``. + + Returns + ------- + str + The fixture's name as pytest will inject it into test functions. + When ``@pytest.fixture(name='alias')`` is used, returns ``'alias'`` + rather than the underlying function name. + """ + if self._fixture_public_name: + return self._fixture_public_name + return str( + getattr(self.object, "name", None) or _get_fixture_fn(self.object).__name__ + ) + + def format_signature(self, **kwargs: t.Any) -> str: + """Return ``() -> ReturnType`` so Sphinx can parse the directive argument. + + The ``()`` is required for ``py_sig_re`` to match a ``->`` return + annotation. ``needs_arglist()`` returns ``False``, so the ``()`` is + suppressed in the rendered output — the reader sees only + ``fixture name -> ReturnType``. + + Returns + ------- + str + Signature string of the form ``() -> ReturnType``, or empty string + when no return annotation is present. + """ + ret = _get_return_annotation(self.object) + if ret is inspect.Parameter.empty: + return "()" + return f"() -> {_format_type_short(ret)}" + + def format_args(self, **kwargs: t.Any) -> str: + """Return empty string — no argument list is shown to users. + + Returns + ------- + str + Always ``""``. + """ + return "" + + def get_doc(self) -> list[list[str]] | None: + """Extract the docstring from the wrapped function, not the fixture wrapper. + + Returns + ------- + list[list[str]] or None + Docstring lines or empty list if no docstring. + """ + fn = _get_fixture_fn(self.object) + docstring = inspect.getdoc(fn) + if docstring: + return [docstring.splitlines()] + return [] + + def add_directive_header(self, sig: str) -> None: + """Emit the directive header with fixture-specific options. + + Also registers ``FixtureMeta`` in the env store for reverse dep + tracking and incremental-build correctness. + + Parameters + ---------- + sig : str + The formatted signature string. + """ + super().add_directive_header(sig) + sourcename = self.get_sourcename() + marker = _get_fixture_marker(self.object) + + scope = marker.scope + self.add_line(f" :scope: {scope}", sourcename) + + if marker.autouse: + self.add_line(" :autouse:", sourcename) + + # Use the config-driven hidden set so pytest_fixture_hidden_dependencies + # in conf.py suppresses deps from the directive header too. + hidden_cfg: frozenset[str] = getattr( + self.env.app.config, + _CONFIG_HIDDEN_DEPS, + PYTEST_HIDDEN, + ) + user_deps = _get_user_deps(self.object, hidden=hidden_cfg) + if user_deps: + dep_names = ", ".join(name for name, _ in user_deps) + self.add_line(f" :depends: {dep_names}", sourcename) + + ret = _get_return_annotation(self.object) + if ret is not inspect.Parameter.empty: + self.add_line(f" :return-type: {_format_type_short(ret)}", sourcename) + + explicit_kind = self.options.get("kind") + kind = _infer_kind(self.object, explicit_kind=explicit_kind) + self.add_line(f" :kind: {kind}", sourcename) + + # Register fixture metadata in the env store for reverse-dep tracking. + # Pass already-resolved kind to avoid a second _infer_kind call. + public_name = self.format_name() + source_name = self.objpath[-1] if self.objpath else public_name + + # Extract teardown summary before registering so the store gets the + # correct value; PyFixtureDirective will overwrite it again later but + # this ensures _validate_store sees the summary. + teardown_text = _extract_teardown_summary(self.object) + + meta = _register_fixture_meta( + env=self.env, + docname=self.env.docname, + obj=self.object, + public_name=public_name, + source_name=source_name, + modname=self.modname, + kind=kind, + app=self.env.app, + teardown_summary=teardown_text, + ) + + # Emit teardown/async flags derived from the fixture function. + if meta.has_teardown: + self.add_line(" :teardown:", sourcename) + if teardown_text: + self.add_line(f" :teardown-summary: {teardown_text}", sourcename) + if meta.is_async: + self.add_line(" :async:", sourcename) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_index.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_index.py new file mode 100644 index 0000000..ca1bf8b --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_index.py @@ -0,0 +1,282 @@ +"""Index table generation helpers for sphinx_autodoc_pytest_fixtures.""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from sphinx import addnodes +from sphinx.domains.python import PythonDomain +from sphinx.util import logging as sphinx_logging +from sphinx.util.nodes import make_refnode + +from sphinx_autodoc_pytest_fixtures._badges import _build_badge_group_node +from sphinx_autodoc_pytest_fixtures._constants import ( + _IDENTIFIER_PATTERN, + _INDEX_TABLE_COLUMNS, + _RST_INLINE_PATTERN, +) +from sphinx_autodoc_pytest_fixtures._css import _CSS +from sphinx_autodoc_pytest_fixtures._models import FixtureMeta, autofixture_index_node +from sphinx_autodoc_pytest_fixtures._store import FixtureStoreDict + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = sphinx_logging.getLogger(__name__) + + +def _parse_rst_inline( + text: str, + app: Sphinx, + docname: str, +) -> list[nodes.Node]: + """Parse RST inline markup into doctree nodes with resolved cross-refs. + + Handles ``:class:`Target```, ``:fixture:`name```, ````literal````, + and plain text. Cross-references are created as ``pending_xref`` nodes + and resolved via ``env.resolve_references()``. + + Parameters + ---------- + text : str + RST inline text, e.g. ``"Return new :class:`libtmux.Server`."``. + app : Sphinx + The Sphinx application (for builder and env access). + docname : str + Current document name (for relative URI resolution). + + Returns + ------- + list[nodes.Node] + Sequence of text, literal, and reference nodes ready for insertion. + """ + result_nodes: list[nodes.Node] = [] + + # Tokenise: :role:`content`, ``literal``, or plain text + pattern = _RST_INLINE_PATTERN + pos = 0 + for m in pattern.finditer(text): + # Plain text before match + if m.start() > pos: + result_nodes.append(nodes.Text(text[pos : m.start()])) + + if m.group(1): + # :role:`content` — build a pending_xref + role = m.group(1) + content = m.group(2) + + # Handle ~ shortening prefix + if content.startswith("~"): + target = content[1:] + display = target.rsplit(".", 1)[-1] + elif "<" in content and ">" in content: + display = content.split("<")[0].strip() + target = content.split("<")[1].rstrip(">").strip() + else: + target = content + display = content.rsplit(".", 1)[-1] + + xref = addnodes.pending_xref( + "", + nodes.literal(display, display, classes=["xref", "py", f"py-{role}"]), + refdomain="py", + reftype=role, + reftarget=target, + refexplicit=True, + refwarn=True, + ) + xref["refdoc"] = docname + result_nodes.append(xref) + + elif m.group(3): + # ``literal`` — inline code + result_nodes.append(nodes.literal(m.group(3), m.group(3), classes=["code"])) + elif m.group(4): + # `interpreted text` — render as inline code (Sphinx default role + # in the Python domain is :obj:, which renders as code) + result_nodes.append(nodes.literal(m.group(4), m.group(4))) + + pos = m.end() + + # Trailing plain text + if pos < len(text): + result_nodes.append(nodes.Text(text[pos:])) + + # Resolve pending_xref nodes via env.resolve_references + if any(isinstance(n, addnodes.pending_xref) for n in result_nodes): + from sphinx.util.docutils import new_document + + temp_doc = new_document("") + temp_para = nodes.paragraph() + for n in result_nodes: + temp_para += n + temp_doc += temp_para + app.env.resolve_references(temp_doc, docname, app.builder) + # Extract resolved nodes from the temp paragraph + result_nodes = list(temp_para.children) + + return result_nodes + + +def _build_return_type_nodes( + meta: FixtureMeta, + py_domain: PythonDomain, + app: Sphinx, + docname: str, +) -> list[nodes.Node]: + """Build doctree nodes for the return type, with linked class/builtin names. + + Tokenises the ``return_display`` string and wraps every identifier in a + ``:class:`` cross-reference. ``env.resolve_references()`` then resolves + identifiers it knows (``str`` \u2192 Python docs via intersphinx, ``Server`` \u2192 + local API page) and leaves unknown ones as plain code literals. + + Parameters + ---------- + meta : FixtureMeta + Fixture metadata containing ``return_display``. + py_domain : PythonDomain + Python domain for object lookup. + app : Sphinx + Sphinx application. + docname : str + Current document name. + + Returns + ------- + list[nodes.Node] + Nodes for the return type cell with cross-referenced identifiers. + """ + display = meta.return_display + if not display: + return [nodes.Text("")] + + # Tokenise: identifiers (including dotted) vs punctuation/whitespace. + # Every identifier gets wrapped in :class:`~name` so intersphinx and + # the Python domain can resolve it. Punctuation passes through as text. + rst_parts: list[str] = [] + for token in _IDENTIFIER_PATTERN.split(display): + if not token: + continue + if _IDENTIFIER_PATTERN.fullmatch(token): + rst_parts.append(f":class:`~{token}`") + else: + rst_parts.append(token) + + rst_text = "".join(rst_parts) + return _parse_rst_inline(rst_text, app, docname) + + +def _resolve_fixture_index( + node: autofixture_index_node, + store: FixtureStoreDict, + py_domain: PythonDomain, + app: Sphinx, + docname: str, +) -> None: + """Replace a :class:`autofixture_index_node` with a docutils table. + + Builds a 4-column table (Fixture, Flags, Returns, Description). + Scope, kind, autouse, and deprecated appear as badges in the Flags column. + Fixture names and return types are cross-referenced; description text + has RST inline markup parsed and rendered. + + Parameters + ---------- + node : autofixture_index_node + The placeholder node to replace. + store : FixtureStoreDict + The finalized fixture store. + py_domain : PythonDomain + Python domain for cross-reference resolution. + app : Sphinx + The Sphinx application. + docname : str + Current document name. + """ + modname = node["module"] + exclude: set[str] = node.get("exclude", set()) + + fixtures = [ + meta + for canon, meta in sorted(store["fixtures"].items()) + if canon.startswith(f"{modname}.") and meta.public_name not in exclude + ] + + if not fixtures: + node.replace_self([]) + return + + table = nodes.table(classes=[_CSS.FIXTURE_INDEX]) + tgroup = nodes.tgroup(cols=len(_INDEX_TABLE_COLUMNS)) + table += tgroup + for _header, width in _INDEX_TABLE_COLUMNS: + tgroup += nodes.colspec(colwidth=width) + + thead = nodes.thead() + tgroup += thead + header_row = nodes.row() + thead += header_row + for header, _width in _INDEX_TABLE_COLUMNS: + entry = nodes.entry() + entry += nodes.paragraph("", header) + header_row += entry + + tbody = nodes.tbody() + tgroup += tbody + for meta in fixtures: + row = nodes.row() + tbody += row + + # --- Fixture name: cross-ref link --- + name_entry = nodes.entry() + obj_entry = py_domain.objects.get(meta.canonical_name) + if obj_entry is not None: + ref_node: nodes.Node = make_refnode( + app.builder, + docname, + obj_entry.docname, + obj_entry.node_id, + nodes.literal(meta.public_name, meta.public_name), + ) + else: + ref_node = nodes.literal(meta.public_name, meta.public_name) + name_para = nodes.paragraph() + name_para += ref_node + name_entry += name_para + row += name_entry + + # --- Flags: scope/kind/autouse/deprecated badges --- + flags_entry = nodes.entry() + flags_para = nodes.paragraph() + flags_para += _build_badge_group_node( + scope=meta.scope, + kind=meta.kind, + autouse=meta.autouse, + deprecated=bool(meta.deprecated), + show_fixture_badge=True, + ) + flags_entry += flags_para + row += flags_entry + + # --- Returns: linked type name --- + ret_entry = nodes.entry() + ret_para = nodes.paragraph() + for ret_node in _build_return_type_nodes(meta, py_domain, app, docname): + ret_para += ret_node + ret_entry += ret_para + row += ret_entry + + # --- Description: parsed RST inline markup --- + desc_entry = nodes.entry() + desc_para = nodes.paragraph() + if meta.summary: + for desc_node in _parse_rst_inline(meta.summary, app, docname): + desc_para += desc_node + desc_entry += desc_para + row += desc_entry + + scroll_wrapper = nodes.container(classes=[_CSS.TABLE_SCROLL]) + scroll_wrapper += table + node.replace_self([scroll_wrapper]) diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py new file mode 100644 index 0000000..a61d100 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_metadata.py @@ -0,0 +1,413 @@ +"""Metadata extraction/registration and usage snippet helpers.""" + +from __future__ import annotations + +import ast +import inspect +import re +import typing as t + +from docutils import nodes +from sphinx import addnodes +from sphinx.util import logging as sphinx_logging + +from sphinx_autodoc_pytest_fixtures._constants import ( + _CALLOUT_MESSAGES, + _KNOWN_KINDS, +) +from sphinx_autodoc_pytest_fixtures._detection import ( + _classify_deps, + _format_type_short, + _get_fixture_fn, + _get_fixture_marker, + _get_return_annotation, + _infer_kind, +) +from sphinx_autodoc_pytest_fixtures._models import FixtureDep, FixtureMeta +from sphinx_autodoc_pytest_fixtures._store import _get_spf_store + +if t.TYPE_CHECKING: + pass + +logger = sphinx_logging.getLogger(__name__) + + +def _is_type_checking_guard(node: ast.If) -> bool: + """Return True if *node* is an ``if TYPE_CHECKING:`` guard.""" + test = node.test + # Handles: TYPE_CHECKING, typing.TYPE_CHECKING, t.TYPE_CHECKING, etc. + return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( + isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING" + ) + + +def _qualify_forward_ref(name: str, fn: t.Any) -> str | None: + """Try to resolve a bare forward-reference string to a qualified name. + + When a fixture's return type is behind ``if TYPE_CHECKING:``, the + annotation is a bare string like ``"Session"`` instead of the class. + This helper inspects the fixture module's source AST to find the + ``from X import Y`` that provides the name, even when inside a + ``TYPE_CHECKING`` guard. + + Parameters + ---------- + name : str + The bare class name (e.g. ``"Session"``). + fn : Any + The fixture's underlying function, used to find its module. + + Returns + ------- + str or None + The fully-qualified name (e.g. ``"libtmux.session.Session"``), + or ``None`` if resolution fails. + """ + import sys + + module = getattr(fn, "__module__", None) + if not module: + return None + mod = sys.modules.get(module) + if mod is None: + return None + + # Fast path: name is available at runtime (not behind TYPE_CHECKING). + obj = getattr(mod, name, None) + if obj is not None and hasattr(obj, "__module__") and hasattr(obj, "__qualname__"): + return f"{obj.__module__}.{obj.__qualname__}" + + # Slow path: parse the module source to find TYPE_CHECKING imports. + try: + source = inspect.getsource(mod) + except (OSError, TypeError): + return None + + try: + tree = ast.parse(source) + except SyntaxError: + return None + + # Restrict to TYPE_CHECKING blocks only so a runtime import of the same + # name from a different module does not steal the cross-reference. + for node in ast.walk(tree): + if isinstance(node, ast.If) and _is_type_checking_guard(node): + for child in ast.walk(node): + if isinstance(child, ast.ImportFrom) and child.module: + for alias in child.names: + imported_name = alias.asname if alias.asname else alias.name + if imported_name == name: + return f"{child.module}.{alias.name}" + + return None + + +def _extract_summary(obj: t.Any) -> str: + """Return the first sentence of the fixture docstring, preserving RST markup. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + + Returns + ------- + str + First sentence with RST markup intact (e.g. ``:class:`Server```), + or empty string if no docstring. + """ + fn = _get_fixture_fn(obj) + doc = inspect.getdoc(fn) or "" + first_para = doc.split("\n\n")[0].replace("\n", " ").strip() + match = re.match(r"^(.*?[.!?])(?:\s|$)", first_para) + return match.group(1) if match else first_para + + +_TEARDOWN_HEADINGS: frozenset[str] = frozenset({"teardown", "cleanup", "finalizer"}) + + +def _extract_teardown_summary(obj: t.Any) -> str | None: + """Return the first line of the Teardown section from the fixture docstring. + + Parameters + ---------- + obj : Any + A pytest fixture wrapper object. + + Returns + ------- + str or None + The first non-blank line(s) of the ``Teardown`` / ``Cleanup`` / + ``Finalizer`` section (NumPy-style heading), or ``None`` when absent. + """ + fn = _get_fixture_fn(obj) + doc = inspect.getdoc(fn) or "" + lines = doc.splitlines() + for i, line in enumerate(lines): + if ( + line.strip().lower() in _TEARDOWN_HEADINGS + and i + 1 < len(lines) + and set(lines[i + 1].strip()) <= {"-"} + ): + body: list[str] = [] + for j in range(i + 2, len(lines)): + stripped = lines[j].strip() + if stripped: + body.append(stripped) + elif body: + break + if body: + return " ".join(body) + return None + + +def _register_fixture_meta( + env: t.Any, + docname: str, + obj: t.Any, + public_name: str, + source_name: str, + modname: str, + kind: str, + app: t.Any, + *, + deprecated: str | None = None, + replacement: str | None = None, + teardown_summary: str | None = None, +) -> FixtureMeta: + """Build and register a FixtureMeta for *obj* in the env store. + + Parameters + ---------- + env : Any + The Sphinx build environment. + docname : str + The current document name. + obj : Any + The pytest fixture wrapper object. + public_name : str + The injection name (alias or function name). + source_name : str + The real module attribute name. + modname : str + The module name. + kind : str + Explicit kind override, or empty to auto-infer. + app : Any + The Sphinx application. + + Returns + ------- + FixtureMeta + The newly created and registered fixture metadata. + """ + canonical_name = f"{modname}.{public_name}" + marker = _get_fixture_marker(obj) + scope = marker.scope + autouse = marker.autouse + params_seq = marker.params or () + param_reprs = tuple(repr(p) for p in params_seq) + + fn = _get_fixture_fn(obj) + has_teardown = inspect.isgeneratorfunction(fn) or inspect.isasyncgenfunction(fn) + is_async = inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn) + + ret_ann = _get_return_annotation(obj) + return_display = ( + _format_type_short(ret_ann) if ret_ann is not inspect.Parameter.empty else "" + ) + # Simple class name for xref: only for bare names without special chars. + # When the annotation is a forward-reference string (from TYPE_CHECKING), + # try to qualify it via the module's TYPE_CHECKING imports so Sphinx can + # resolve cross-references (e.g. "Session" → "libtmux.session.Session"). + return_xref_target: str | None = None + if return_display and return_display.isidentifier(): + return_xref_target = return_display + if isinstance(ret_ann, str): + qualified = _qualify_forward_ref(return_display, fn) + if qualified: + return_xref_target = qualified + return_display = qualified + + inferred_kind = _infer_kind(obj, kind or None) + if inferred_kind not in _KNOWN_KINDS: + logger.warning( + "unknown fixture kind %r for %r; expected one of %r", + inferred_kind, + canonical_name, + sorted(_KNOWN_KINDS), + ) + + # Build classified deps. + project_deps, builtin_deps, _hidden = _classify_deps(obj, app) + dep_list: list[FixtureDep] = [] + for dep_name in project_deps: + dep_store = _get_spf_store(env) + target_canon = dep_store["public_to_canon"].get(dep_name) + dep_list.append( + FixtureDep( + display_name=dep_name, + kind="fixture", + target=target_canon, + ) + ) + for dep_name, url in builtin_deps.items(): + dep_list.append(FixtureDep(display_name=dep_name, kind="builtin", url=url)) + + meta = FixtureMeta( + docname=docname, + canonical_name=canonical_name, + public_name=public_name, + source_name=source_name, + scope=scope, + autouse=autouse, + kind=inferred_kind, + return_display=return_display, + return_xref_target=return_xref_target, + deps=tuple(dep_list), + param_reprs=param_reprs, + has_teardown=has_teardown, + is_async=is_async, + summary=_extract_summary(obj), + deprecated=deprecated, + replacement=replacement, + teardown_summary=teardown_summary, + ) + + store = _get_spf_store(env) + store["fixtures"][canonical_name] = meta + + # Update public_to_canon mapping (fast-path; _finalize_store is backstop). + if public_name not in store["public_to_canon"]: + store["public_to_canon"][public_name] = canonical_name + elif store["public_to_canon"][public_name] != canonical_name: + store["public_to_canon"][public_name] = None # ambiguous + + # Build reverse_deps for each project dep. + for dep in dep_list: + if dep.kind == "fixture" and dep.target: + store["reverse_deps"].setdefault(dep.target, []) + if canonical_name not in store["reverse_deps"][dep.target]: + store["reverse_deps"][dep.target].append(canonical_name) + + return meta + + +# --------------------------------------------------------------------------- +# Usage snippet and layout helpers +# --------------------------------------------------------------------------- + + +def _has_authored_example(content_node: nodes.Element) -> bool: + """Return True if *content_node* already contains authored examples. + + Only inspects direct children — does not recurse into nested containers. + This keeps the detection narrow and predictable: a ``rubric`` titled + "Example" buried inside an unrelated admonition will not suppress the + auto-generated usage snippet. + """ + for child in content_node.children: + if isinstance(child, nodes.doctest_block): + return True + if isinstance(child, nodes.rubric) and child.astext() in { + "Example", + "Examples", + }: + return True + return False + + +def _build_usage_snippet( + fixture_name: str, + ret_type: str | None, + kind: str, + scope: str, + autouse: bool, +) -> nodes.Node | None: + """Return a doctree node for the kind-appropriate usage example. + + Parameters + ---------- + fixture_name : str + The fixture's injection name. + ret_type : str | None + The fixture's return type string, or empty/None when absent. + kind : str + One of ``"resource"``, ``"factory"``, or ``"override_hook"``. + scope : str + The fixture scope (used in the conftest decorator for override hooks). + autouse : bool + When True, returns a note admonition instead of a test snippet. + + Returns + ------- + nodes.Node | None + A ``literal_block`` or ``note`` node, or ``None`` for autouse fixtures. + + Notes + ----- + * ``resource`` → ``None`` (trivially obvious to pytest users) + * ``factory`` → ``def test_example(Name) -> None: obj = Name(); ...`` + * ``override_hook`` → ``conftest.py`` snippet with ``@pytest.fixture`` override + * ``autouse`` → ``nodes.note`` (no test snippet needed) + """ + if autouse: + note = nodes.note() + note += nodes.paragraph( + "", + _CALLOUT_MESSAGES["autouse"], + ) + return note + + if kind == "override_hook": + scope_decorator = ( + f'@pytest.fixture(scope="{scope}")\n' + if scope != "function" + else "@pytest.fixture\n" + ) + ret_ann = f" -> {ret_type}" if ret_type else "" + code = ( + "# conftest.py\n" + "import pytest\n\n\n" + f"{scope_decorator}" + f"def {fixture_name}(){ret_ann}:\n" + " return ... # your value here\n" + ) + elif kind == "factory": + type_ann = f": {ret_type}" if ret_type else "" + code = ( + f"def test_example({fixture_name}{type_ann}) -> None:\n" + f" obj = {fixture_name}()\n" + " assert obj is not None\n" + ) + else: + # Resource fixtures — generic snippet like + # ``def test_example(server: Server): ...`` is trivially obvious + # to any pytest user and adds nothing beyond the signature. + return None + + return nodes.literal_block(code, code, language="python") + + +def _summary_insert_index(content_node: addnodes.desc_content) -> int: + """Return insertion index just after the first paragraph in content_node. + + The first paragraph is the docstring summary sentence. Metadata and + snippets should follow it (five-zone layout: sig → summary → metadata + → usage → body). + + Parameters + ---------- + content_node : addnodes.desc_content + The directive's content node. + + Returns + ------- + int + Index of the node slot immediately after the first paragraph child, + or ``0`` when no paragraph is found. + """ + for i, child in enumerate(content_node.children): + if isinstance(child, nodes.paragraph): + return i + 1 + return 0 diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_models.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_models.py new file mode 100644 index 0000000..793a600 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_models.py @@ -0,0 +1,150 @@ +"""Data models, dataclasses, protocols, and node types for sphinx_autodoc_pytest_fixtures.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from docutils import nodes + +from sphinx_autodoc_pytest_fixtures._constants import _DEFAULTS + + +@dataclass(frozen=True) +class FixtureDep: + """A classified fixture dependency. + + All fields are primitive types so that ``FixtureDep`` can be pickled + safely when stored in the Sphinx build environment. + """ + + display_name: str + """Short display name, e.g. ``"config_file"``.""" + + kind: t.Literal["fixture", "builtin", "external", "unresolved"] + """Classification of the dependency.""" + + target: str | None = None + """Canonical name for project fixtures (used for reverse deps).""" + + url: str | None = None + """External URL for builtin/external deps.""" + + +@dataclass(frozen=True) +class FixtureMeta: + """Env-safe fixture metadata stored per fixture in the build environment. + + All fields must be pickle-safe primitives — never store raw annotation + objects (``type``, generics) here as they are not reliably picklable. + + Stored at ``env.domaindata["sphinx_autodoc_pytest_fixtures"]["fixtures"][canonical_name]``. + """ + + docname: str + """Sphinx docname of the page where this fixture is documented.""" + + canonical_name: str + """Fully-qualified name, e.g. ``"libtmux.pytest_plugin.server"``.""" + + public_name: str + """Pytest injection name, e.g. ``"server"`` (alias or function name).""" + + source_name: str + """Real module attribute name, e.g. ``"_server"``.""" + + scope: str + """Fixture scope: ``"function"``, ``"session"``, ``"module"``, or ``"class"``.""" + + autouse: bool + """Whether the fixture runs automatically for every test.""" + + kind: str + """Fixture kind: one of :data:`FixtureKind` values, or a custom string.""" + + return_display: str + """Short type label, e.g. ``"Server"``.""" + + return_xref_target: str | None + """Simple class name for cross-referencing, or ``None`` for complex types.""" + + deps: tuple[FixtureDep, ...] + """Classified fixture dependencies.""" + + param_reprs: tuple[str, ...] + """``repr()`` of each parametrize value from the fixture marker.""" + + has_teardown: bool + """True when the fixture is a generator (yield-based) fixture.""" + + is_async: bool + """True when the fixture is an async function or async generator.""" + + summary: str + """First sentence of the fixture docstring (raw RST markup preserved).""" + + deprecated: str | None = None + """Version string when the fixture is deprecated, or ``None``.""" + + replacement: str | None = None + """Canonical name of the replacement fixture, or ``None``.""" + + teardown_summary: str | None = None + """Short description of teardown/cleanup behavior, or ``None``.""" + + +class autofixture_index_node(nodes.General, nodes.Element): + """Placeholder replaced during ``doctree-resolved`` with a fixture index table.""" + + +# --------------------------------------------------------------------------- +# Protocol for fixture marker (structural type for mypy safety) +# --------------------------------------------------------------------------- + + +class _FixtureMarker(t.Protocol): + """Normalised fixture metadata — scope is ALWAYS a plain str.""" + + @property + def scope(self) -> str: ... # never None, never Scope enum + + @property + def autouse(self) -> bool: ... + + @property + def params(self) -> t.Sequence[t.Any] | None: ... + + @property + def name(self) -> str | None: ... + + +class _FixtureFunctionDefinitionAdapter: + """Adapter: normalises pytest 9+ FixtureFunctionDefinition to _FixtureMarker. + + pytest 9+: .scope is a _pytest.scope.Scope enum — .value is the lowercase str. + pytest <9: .scope may be str or None (None means function-scope). + """ + + __slots__ = ("_obj",) + + def __init__(self, obj: t.Any) -> None: + self._obj = obj + + @property + def scope(self) -> str: + raw = self._obj.scope + if hasattr(raw, "value"): # pytest 9+: _pytest.scope.Scope enum + return str(raw.value) + return str(raw) if raw else _DEFAULTS["scope"] + + @property + def autouse(self) -> bool: + return bool(self._obj.autouse) + + @property + def params(self) -> t.Sequence[t.Any] | None: + return self._obj.params # type: ignore[no-any-return] + + @property + def name(self) -> str | None: + return self._obj.name # type: ignore[no-any-return] diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css new file mode 100644 index 0000000..73520b3 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_static/css/sphinx_autodoc_pytest_fixtures.css @@ -0,0 +1,394 @@ +/* ── sphinx_autodoc_pytest_fixtures ──────────────────────── + * Multi-badge group: scope + kind/state + FIXTURE + * Three slots, hard ceiling: at most 3 badges per fixture. + * + * Slot 1 (scope): session=amber, module=teal, class=slate + * function → suppressed (absence = function-scope) + * Slot 2 (kind): factory=amber/brown, override_hook=violet + * resource → suppressed (default, no badge needed) + * OR state: autouse=rose (replaces kind when autouse=True) + * Slot 3 (base): FIXTURE — always shown, green + * + * Uses nodes.inline (portable across all Sphinx builders). + * Class-based selectors replace data-* attribute selectors. + * Flex layout replaces float with mobile-responsive wrapping. + * ────────────────────────────────────────────────────────── */ + +/* Token system */ +:root { + /* Base FIXTURE badge — outlined green. 7.2:1 fg-on-bg (WCAG AA+AAA) */ + --spf-fixture-bg: #e6f7ed; + --spf-fixture-fg: #1a5c2e; + --spf-fixture-border: #3aad65; + + /* Scope: session — amber/gold. 6.2:1 fg-on-bg (WCAG AA) */ + --spf-scope-session-bg: #fff3cd; + --spf-scope-session-fg: #7a5200; + --spf-scope-session-border: #d4a017; + + /* Scope: module — teal. 6.7:1 fg-on-bg (WCAG AA) */ + --spf-scope-module-bg: #e0f4f4; + --spf-scope-module-fg: #1a5c5c; + --spf-scope-module-border: #3aabab; + + /* Scope: class — slate. 9.3:1 fg-on-bg (WCAG AA+AAA) */ + --spf-scope-class-bg: #eeedf6; + --spf-scope-class-fg: #3c3670; + --spf-scope-class-border: #7b76c0; + + /* Kind: factory — amber/brown (outlined). 8.1:1 fg-on-white (WCAG AA+AAA) */ + --spf-kind-factory-bg: transparent; + --spf-kind-factory-fg: #7a4200; + --spf-kind-factory-border: #c87f35; + + /* Kind: override_hook — violet (outlined). 11.3:1 fg-on-white (WCAG AA+AAA) */ + --spf-kind-override-bg: transparent; + --spf-kind-override-fg: #5a1a7a; + --spf-kind-override-border: #9b59c8; + + /* State: autouse — rose (outlined). 10.5:1 fg-on-white (WCAG AA+AAA) */ + --spf-state-autouse-bg: transparent; + --spf-state-autouse-fg: #7a1a2a; + --spf-state-autouse-border: #c85070; + + /* State: deprecated — muted red/grey (outlined). 8.5:1 fg-on-white */ + --spf-deprecated-bg: transparent; + --spf-deprecated-fg: #8a4040; + --spf-deprecated-border: #c07070; + + /* Shared badge metrics */ + --spf-badge-font-size: 0.67rem; + --spf-badge-padding-v: 0.16rem; + --spf-badge-border-w: 1px; +} + +/* Dark mode — OS-level */ +@media (prefers-color-scheme: dark) { + body:not([data-theme="light"]) { + --spf-fixture-bg: #0d2e1a; + --spf-fixture-fg: #70dd90; + --spf-fixture-border: #309050; + + --spf-scope-session-bg: #3a2800; + --spf-scope-session-fg: #f5d580; + --spf-scope-session-border: #c89030; + + --spf-scope-module-bg: #0d2a2a; + --spf-scope-module-fg: #70dddd; + --spf-scope-module-border: #309090; + + --spf-scope-class-bg: #1a1838; + --spf-scope-class-fg: #b0acee; + --spf-scope-class-border: #6060b0; + + --spf-kind-factory-fg: #f0b060; + --spf-kind-factory-border: #d08040; + + --spf-kind-override-fg: #d090f0; + --spf-kind-override-border: #a060d0; + + --spf-state-autouse-fg: #f080a0; + --spf-state-autouse-border: #d05070; + + --spf-deprecated-fg: #e08080; + --spf-deprecated-border: #c06060; + } +} + +/* Furo explicit dark toggle — must match OS-dark block above */ +body[data-theme="dark"] { + --spf-fixture-bg: #0d2e1a; /* 8.7:1 with fg #70dd90 */ + --spf-fixture-fg: #70dd90; + --spf-fixture-border: #309050; + + --spf-scope-session-bg: #3a2800; + --spf-scope-session-fg: #f5d580; + --spf-scope-session-border: #c89030; + + --spf-scope-module-bg: #0d2a2a; + --spf-scope-module-fg: #70dddd; + --spf-scope-module-border: #309090; + + --spf-scope-class-bg: #1a1838; + --spf-scope-class-fg: #b0acee; + --spf-scope-class-border: #6060b0; + + --spf-kind-factory-fg: #f0b060; + --spf-kind-factory-border: #d08040; + + --spf-kind-override-fg: #d090f0; + --spf-kind-override-border: #a060d0; + + --spf-state-autouse-fg: #f080a0; + --spf-state-autouse-border: #d05070; + + --spf-deprecated-fg: #e08080; + --spf-deprecated-border: #c06060; +} + +/* "fixture" keyword prefix — keep Furo's default keyword colour */ +dl.py.fixture > dt em.property { + color: var(--color-api-keyword); + font-style: normal; +} + +/* Badge group container — flex layout for reliable multi-badge alignment. + * Flexbox replaces the float: right approach; the dt becomes a flex row + * (signature text + badge group side by side). + * On narrow viewports the badge group wraps below the signature. */ +dl.py.fixture > dt { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; + background: var(--color-background-secondary); + border-bottom: 1px solid var(--color-background-border); + padding: 0.5rem 0.75rem; +} + +/* Visual reorder: sig elements (0) → ¶ (1) → badges (2) → [source] (3). + * The ¶ headerlink is injected by Sphinx's HTML translator AFTER our + * doctree code runs, so CSS order is the only way to position it. */ +dl.py.fixture > dt > .headerlink { order: 1; } +dl.py.fixture > dt > .spf-badge-group { order: 2; } +dl.py.fixture > dt > a.reference.external { order: 3; } + +dl.py.fixture > dt .spf-badge-group { + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; + margin-left: auto; + white-space: nowrap; + text-indent: 0; +} + +/* Mobile: badge group wraps below signature text, shares row with actions */ +@media (max-width: 52rem) { + dl.py.fixture > dt .spf-badge-group { + margin-left: 0; + white-space: normal; + flex-wrap: wrap; + } + dl.py.fixture > dt .spf-badge { + --spf-badge-font-size: 0.75rem; + } +} + +/* Shared badge base — component-scoped. + * Applies wherever .spf-badge appears: fixture cards, index tables, or any + * future context. Context-specific layout rules (flex container, tooltip + * positioning anchor, margin-left: auto in dt) remain on their containers. */ +.spf-badge { + position: relative; /* positioning context for the CSS-only tooltip */ + display: inline-block; + font-size: var(--spf-badge-font-size, 0.67rem); + font-weight: 650; + line-height: normal; + letter-spacing: 0.01em; + padding: var(--spf-badge-padding-v, 0.16rem) 0.5rem; + border-radius: 0.22rem; + border: var(--spf-badge-border-w, 1px) solid; + vertical-align: middle; +} + +/* Touch/keyboard tooltip — shows on focus (touch tap focuses the element). + * Sphinx's tooltips are invisible on touch devices; this CSS-only + * solution renders the title text as a positioned pseudo-element on :focus. + * Works in both fixture cards and index table cells since .spf-badge carries + * position: relative as the tooltip's containing block. */ +.spf-badge[tabindex]:focus::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + background: var(--color-background-primary); + border: 1px solid var(--color-background-border); + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 400; + white-space: nowrap; + border-radius: 0.2rem; + z-index: 10; + pointer-events: none; +} + +/* Restore visible focus outline for keyboard users. The tooltip still appears + * on any :focus, but the ring only appears for :focus-visible (keyboard nav). */ +.spf-badge[tabindex]:focus-visible { + outline: 2px solid var(--color-link); + outline-offset: 2px; +} + +/* FIXTURE badge (always shown, outlined green — same visual treatment + * as scope badges: light bg, dark text, colored border) */ +.spf-badge--fixture { + background-color: var(--spf-fixture-bg); + color: var(--spf-fixture-fg); + border-color: var(--spf-fixture-border); +} + +/* Scope badges — component-scoped (replaces data-* attribute selectors) */ +.spf-scope-session { + background-color: var(--spf-scope-session-bg); + color: var(--spf-scope-session-fg); + border-color: var(--spf-scope-session-border); + letter-spacing: 0.03em; +} +.spf-scope-module { + background-color: var(--spf-scope-module-bg); + color: var(--spf-scope-module-fg); + border-color: var(--spf-scope-module-border); +} +.spf-scope-class { + background-color: var(--spf-scope-class-bg); + color: var(--spf-scope-class-fg); + border-color: var(--spf-scope-class-border); +} + +/* Kind badges (outlined — behaviour, not lifecycle) */ +.spf-factory { + background-color: var(--spf-kind-factory-bg); + color: var(--spf-kind-factory-fg); + border-color: var(--spf-kind-factory-border); +} +.spf-override { + background-color: var(--spf-kind-override-bg); + color: var(--spf-kind-override-fg); + border-color: var(--spf-kind-override-border); +} + +/* State badge (autouse) */ +.spf-autouse { + background-color: var(--spf-state-autouse-bg); + color: var(--spf-state-autouse-fg); + border-color: var(--spf-state-autouse-border); +} + +/* Deprecated badge — component-scoped; card muting stays dt-scoped */ +.spf-deprecated { + background-color: var(--spf-deprecated-bg); + color: var(--spf-deprecated-fg); + border-color: var(--spf-deprecated-border); +} + +/* ── abbr[title] specificity fix ─────────────────────────────────────── + * Normalize.css (bundled with Furo) sets on abbr[title]: + * border-bottom: none — removes bottom border entirely + * text-decoration: underline dotted — adds unwanted dotted underline + * Specificity of abbr[title] is (0,1,1) which beats .spf-badge (0,1,0), + * so the bottom border is trimmed and the underline bleeds through. + * + * Fix: use abbr.spf-badge (0,1,1) to win on source order. + * The border-color reset by abbr[title] also needs per-variant overrides + * at the same specificity, otherwise the bottom border colour falls back + * to currentColor (the text fg colour) instead of the border variable. + * ─────────────────────────────────────────────────────────────────────── */ +abbr.spf-badge { + border-bottom-style: solid; + border-bottom-width: var(--spf-badge-border-w, 1px); + text-decoration: underline dotted; +} +abbr.spf-badge--fixture { border-color: var(--spf-fixture-border); } +abbr.spf-scope-session { border-color: var(--spf-scope-session-border); } +abbr.spf-scope-module { border-color: var(--spf-scope-module-border); } +abbr.spf-scope-class { border-color: var(--spf-scope-class-border); } +abbr.spf-factory { border-color: var(--spf-kind-factory-border); } +abbr.spf-override { border-color: var(--spf-kind-override-border); } +abbr.spf-autouse { border-color: var(--spf-state-autouse-border); } +abbr.spf-deprecated { border-color: var(--spf-deprecated-border); } + +dl.py.fixture.spf-deprecated > dt { + opacity: 0.7; +} + +/* Badge group inside fixture index table cell — keep badges inline. + * The card context has its own dl.py.fixture > dt .spf-badge-group rule + * (with margin-left: auto and flex layout tied to the dt flex row). + * Table cells need a simpler inline-flex without the card-specific overrides. */ +.spf-fixture-index .spf-badge-group { + display: inline-flex; + gap: 0.3rem; +} + +/* Suppress module prefix (libtmux.pytest_plugin.) */ +dl.py.fixture > dt .sig-prename.descclassname { + display: none; +} + +/* ── Fixture card treatment ──────────────────────────────── */ +dl.py.fixture { + border: 1px solid var(--color-background-border); + border-radius: 0.5rem; + padding: 0; + margin-bottom: 1.5rem; + overflow: visible; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); +} + +/* Reset Furo's hanging-indent and negative-margin on dt so content + * stays within the card border. Furo sets text-indent: -35px and + * margin: 0 -4px for wrapped signatures — both break our flex card. + * !important on padding overrides Furo's .sig:not(.sig-inline) rule which + * sets padding-top/bottom: 0.25rem and wins on specificity without it. */ +dl.py.fixture > dt { + text-indent: 0; + margin: 0; + padding-left: 1rem; + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + min-height: 2rem; +} + +dl.py.fixture > dd { + padding: 0.75rem 1rem; + margin-left: 0 !important; /* override Furo's dd { margin-left: 32px } */ +} + +/* Metadata fields: compact grid that keeps dt/dd pairs together */ +dl.py.fixture > dd > dl.field-list { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.25rem 1rem; + border-top: 1px solid var(--color-background-border); + padding-top: 0.5rem; + margin-top: 0.5rem; +} +dl.py.fixture > dd > dl.field-list > dt { + grid-column: 1; + font-weight: normal; + text-transform: uppercase; + font-size: 0.85em; + letter-spacing: 0.025em; +} +dl.py.fixture > dd > dl.field-list > dt .colon { display: none; } +dl.py.fixture > dd > dl.field-list > dd { grid-column: 2; margin-left: 0; } + +/* Mobile: metadata fields stack to single column */ +@media (max-width: 52rem) { + dl.py.fixture > dd > dl.field-list { + grid-template-columns: 1fr; + } + dl.py.fixture > dd > dl.field-list > dt, + dl.py.fixture > dd > dl.field-list > dd { + grid-column: 1; + } +} + +/* Suppress Rtype field-list on fixtures — return type is already in the + * signature (→ Type). sphinx_autodoc_typehints emits a separate field-list + * with only "Rtype:" when autodoc_typehints = "description". Hide it. */ +dl.py.fixture > dd > dl.field-list + dl.field-list { + display: none; +} + +/* Horizontal scroll wrapper for the fixture index table on narrow viewports */ +.spf-table-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.spf-table-scroll table { + min-width: 40rem; + width: 100%; +} diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_store.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_store.py new file mode 100644 index 0000000..6197aed --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_store.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import dataclasses +import typing as t + +from sphinx.util import logging as sphinx_logging + +from sphinx_autodoc_pytest_fixtures._constants import ( + _EXTENSION_KEY, + _INTERSPHINX_FIXTURE_ROLE, + _INTERSPHINX_PROJECT, + _STORE_VERSION, + PYTEST_BUILTIN_LINKS, +) +from sphinx_autodoc_pytest_fixtures._models import FixtureDep, FixtureMeta + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = sphinx_logging.getLogger(__name__) + + +def _resolve_builtin_url(name: str, app: t.Any) -> str | None: + """Resolve a pytest builtin fixture URL from intersphinx inventory. + + Falls back to the hardcoded ``PYTEST_BUILTIN_LINKS`` dict when + the intersphinx inventory is unavailable (offline builds, missing + extension, or inventory not yet loaded). + + Parameters + ---------- + name : str + The fixture name to look up (e.g. ``"tmp_path_factory"``). + app : Any + The Sphinx application instance (or None). + + Returns + ------- + str or None + The resolved URL, or None if the fixture is not a known builtin. + """ + try: + inv = getattr(getattr(app, "env", None), "intersphinx_named_inventory", {}) + fixture_inv = inv.get(_INTERSPHINX_PROJECT, {}).get( + _INTERSPHINX_FIXTURE_ROLE, {} + ) + if name in fixture_inv: + _proj, _ver, uri, _dispname = fixture_inv[name] + return str(uri) + except Exception: + pass + result: str | None = PYTEST_BUILTIN_LINKS.get(name) + return result + + +class FixtureStoreDict(t.TypedDict): + """Typed shape of the extension-owned env domaindata namespace.""" + + fixtures: dict[str, FixtureMeta] + public_to_canon: dict[str, str | None] + reverse_deps: dict[str, list[str]] + _store_version: int + + +def _make_empty_store() -> FixtureStoreDict: + """Return a fresh, empty store dict.""" + return FixtureStoreDict( + fixtures={}, + public_to_canon={}, + reverse_deps={}, + _store_version=_STORE_VERSION, + ) + + +def _get_spf_store(env: t.Any) -> FixtureStoreDict: + """Return the extension-owned env domaindata namespace. + + Creates the namespace with empty collections on first access. + + Parameters + ---------- + env : Any + The Sphinx build environment. + + Returns + ------- + FixtureStoreDict + The mutable store dict. + """ + store: FixtureStoreDict = env.domaindata.setdefault( + _EXTENSION_KEY, + _make_empty_store(), + ) + if store.get("_store_version") != _STORE_VERSION: + # Stale pickle — mutate in-place to preserve existing references. + t.cast(dict[str, t.Any], store).clear() + t.cast(dict[str, t.Any], store).update(_make_empty_store()) + return store + + +# --------------------------------------------------------------------------- +# Store finalization — one-shot index rebuild after all docs are read +# --------------------------------------------------------------------------- + + +def _rebuild_public_to_canon(store: FixtureStoreDict) -> None: + """Rebuild ``public_to_canon`` from the ``fixtures`` registry. + + Marks public names that map to multiple canonical names as ``None`` + (ambiguous). + """ + pub_map: dict[str, str | None] = {} + for canon, meta in store["fixtures"].items(): + pub = meta.public_name + if pub in pub_map and pub_map[pub] != canon: + pub_map[pub] = None # ambiguous + else: + pub_map[pub] = canon + store["public_to_canon"] = pub_map + + +def _rebind_dep_targets(store: FixtureStoreDict) -> None: + """Rebind ALL ``FixtureDep.target`` values from the current ``public_to_canon``. + + Handles forward references (``None`` → resolved), stale references + (old canonical → updated/``None``), and purged providers (resolved → + ``None``). Uses ``dataclasses.replace`` on the frozen dataclasses. + """ + p2c = store["public_to_canon"] + updated: dict[str, FixtureMeta] = {} + for canon, meta in store["fixtures"].items(): + new_deps: list[FixtureDep] = [] + changed = False + for dep in meta.deps: + if dep.kind == "fixture": + correct_target = p2c.get(dep.display_name) + if correct_target != dep.target: + new_deps.append(dataclasses.replace(dep, target=correct_target)) + changed = True + continue + new_deps.append(dep) + if changed: + updated[canon] = dataclasses.replace(meta, deps=tuple(new_deps)) + store["fixtures"].update(updated) + + +def _rebuild_reverse_deps(store: FixtureStoreDict) -> None: + """Rebuild ``reverse_deps`` from the finalized ``fixtures`` registry. + + Skips self-edges (a fixture depending on itself). + """ + rev: dict[str, list[str]] = {} + for canon, meta in store["fixtures"].items(): + for dep in meta.deps: + if dep.kind == "fixture" and dep.target and dep.target != canon: + rev.setdefault(dep.target, []) + if canon not in rev[dep.target]: + rev[dep.target].append(canon) + store["reverse_deps"] = {k: sorted(v) for k, v in rev.items()} + + +def _finalize_store(store: FixtureStoreDict) -> None: + """One-shot finalization: rebuild all derived indices from ``fixtures``. + + Called via the ``env-updated`` event, which fires once after all + parallel merges are complete and before ``doctree-resolved``. + """ + _rebuild_public_to_canon(store) + _rebind_dep_targets(store) + _rebuild_reverse_deps(store) + + +def _on_env_updated(app: Sphinx, env: t.Any) -> None: + """Finalize the fixture store after all documents are read and merged. + + Parameters + ---------- + app : Sphinx + The Sphinx application. + env : Any + The Sphinx build environment. + """ + store = _get_spf_store(env) + _finalize_store(store) + + from sphinx_autodoc_pytest_fixtures._validation import _validate_store + + _validate_store(store, app) + + +# --------------------------------------------------------------------------- +# Incremental / parallel build env hooks +# --------------------------------------------------------------------------- + + +def _on_env_purge_doc(app: Sphinx, env: t.Any, docname: str) -> None: + """Remove fixture records for a doc being re-processed. + + Index rebuilds are deferred to :func:`_finalize_store` via ``env-updated``. + + Parameters + ---------- + app : Sphinx + The Sphinx application (unused; required by the hook signature). + env : Any + The Sphinx build environment. + docname : str + The document being purged. + """ + store = _get_spf_store(env) + to_remove = [k for k, v in store["fixtures"].items() if v.docname == docname] + for k in to_remove: + del store["fixtures"][k] + + +def _on_env_merge_info( + app: Sphinx, + env: t.Any, + docnames: list[str], + other: t.Any, +) -> None: + """Merge fixture metadata from parallel-build sub-environments. + + Index rebuilds are deferred to :func:`_finalize_store` via ``env-updated``. + + Parameters + ---------- + app : Sphinx + The Sphinx application (unused; required by the hook signature). + env : Any + The primary (receiving) build environment. + docnames : list[str] + Docnames processed by the sub-environment (unused). + other : Any + The sub-environment whose store is merged into *env*. + """ + store = _get_spf_store(env) + other_store = _get_spf_store(other) + # Explicit loop so we can emit SPF009 when the same fixture canonical name + # appears in both environments with different docnames (parallel build + # collision). Last writer wins — this is warning-only and does not + # participate in lint_level=error because parallel builds are rare and + # treating collisions as errors would break incremental builds. + for canon, meta in other_store["fixtures"].items(): + if ( + canon in store["fixtures"] + and store["fixtures"][canon].docname != meta.docname + ): + logger.warning( + "fixture %r documented from multiple pages (%r and %r); using last", + canon, + store["fixtures"][canon].docname, + meta.docname, + extra={"spf_code": "SPF009"}, + ) + store["fixtures"][canon] = meta diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py new file mode 100644 index 0000000..a556bd1 --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_transforms.py @@ -0,0 +1,325 @@ +"""Doctree-resolved transforms, missing-reference handler, and HTML visitors.""" + +from __future__ import annotations + +import typing as t + +from docutils import nodes +from sphinx import addnodes +from sphinx.domains.python import PythonDomain +from sphinx.util import logging as sphinx_logging +from sphinx.util.nodes import make_refnode +from sphinx.writers.html5 import HTML5Translator + +from sphinx_autodoc_pytest_fixtures._badges import _build_badge_group_node +from sphinx_autodoc_pytest_fixtures._constants import _FIELD_LABELS +from sphinx_autodoc_pytest_fixtures._index import _resolve_fixture_index +from sphinx_autodoc_pytest_fixtures._models import autofixture_index_node +from sphinx_autodoc_pytest_fixtures._store import FixtureStoreDict, _get_spf_store + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = sphinx_logging.getLogger(__name__) + + +def _on_missing_reference( + app: t.Any, + env: t.Any, + node: t.Any, + contnode: t.Any, +) -> t.Any | None: + r"""Resolve ``:func:\`name\``` cross-references to ``py:fixture`` entries. + + Parameters + ---------- + app : Any + The Sphinx application. + env : Any + The Sphinx build environment. + node : Any + The pending cross-reference node. + contnode : Any + The content node to wrap. + + Returns + ------- + Any or None + A resolved reference node, or ``None`` to let Sphinx continue. + + Notes + ----- + Handles MyST ``{func}\\`name\\``` references in ``usage.md`` that predate + the ``py:fixture`` registration. The ``ObjType`` fallback roles cover most + cases; this handler covers the ``any`` and implicit-domain paths. + """ + if node.get("refdomain") != "py": + return None + + reftype = node.get("reftype") + target = node.get("reftarget", "") + py_domain: PythonDomain = env.get_domain("py") + + # Short-name :fixture: lookup via public_to_canon. + if reftype == "fixture": + store = _get_spf_store(env) + canon = store["public_to_canon"].get(target) + if canon: # None means ambiguous — let Sphinx emit standard warning + return py_domain.resolve_xref( + env, + node.get("refdoc", ""), + app.builder, + "fixture", + canon, + node, + contnode, + ) + return None + + # Existing func/obj/any fallback for legacy :func: references. + if reftype not in ("func", "obj", "any"): + return None + + matches = py_domain.find_obj( + env, + node.get("py:module", ""), + node.get("py:class", ""), + target, + "fixture", + 1, + ) + if not matches: + return None + + match_name, _obj_entry = matches[0] + return py_domain.resolve_xref( + env, + node.get("refdoc", ""), + app.builder, + "fixture", + match_name, + node, + contnode, + ) + + +def _inject_badges_and_reorder(sig_node: addnodes.desc_signature) -> None: + """Inject scope/kind/fixture badges and reorder signature children. + + Appends a badge group to *sig_node* and reorders the \u00b6 headerlink and + [source] viewcode link so the visual layout is: + ``name \u2192 return \u2192 \u00b6 \u2192 badges (right-aligned) \u2192 [source]``. + + Guarded by the ``spf_badges_injected`` flag \u2014 safe to call multiple times. + """ + if sig_node.get("spf_badges_injected"): + return + sig_node["spf_badges_injected"] = True + + scope = sig_node.get("spf_scope", "function") + kind = sig_node.get("spf_kind", "resource") + autouse = sig_node.get("spf_autouse", False) + deprecated = sig_node.get("spf_deprecated", False) + + badge_group = _build_badge_group_node(scope, kind, autouse, deprecated=deprecated) + + # Detach [source] and \u00b6 links, then re-append in desired order. + viewcode_ref = None + headerlink_ref = None + for child in list(sig_node.children): + if isinstance(child, nodes.reference): + if child.get("internal") is not True and any( + "viewcode-link" in getattr(gc, "get", lambda *_: "")("classes", []) + for gc in child.children + if isinstance(gc, nodes.inline) + ): + viewcode_ref = child + sig_node.remove(child) + elif "headerlink" in child.get("classes", []): + headerlink_ref = child + sig_node.remove(child) + + if headerlink_ref is not None: + sig_node += headerlink_ref + sig_node += badge_group + if viewcode_ref is not None: + sig_node += viewcode_ref + + +def _strip_rtype_fields(desc_node: addnodes.desc) -> None: + """Remove redundant "Rtype" fields from fixture descriptions. + + ``sphinx_autodoc_typehints`` emits these for all autodoc objects; for + fixtures the return type is already in the signature line (``\u2192 Type``). + """ + for content_child in desc_node.findall(addnodes.desc_content): + for fl in list(content_child.findall(nodes.field_list)): + for field in list(fl.children): + if not isinstance(field, nodes.field): + continue + field_name = field.children[0] if field.children else None + if ( + isinstance(field_name, nodes.field_name) + and field_name.astext().lower() == "rtype" + ): + fl.remove(field) + if not fl.children: + content_child.remove(fl) + + +def _inject_metadata_fields( + desc_node: addnodes.desc, + store: FixtureStoreDict, + py_domain: PythonDomain, + app: Sphinx, + docname: str, +) -> None: + """Inject "Used by" and "Parametrized" fields into fixture descriptions. + + Uses :func:`make_refnode` for "Used by" links because ``pending_xref`` + nodes added during ``doctree-resolved`` are too late for normal reference + resolution. + + Guarded by ``spf_metadata_injected`` \u2014 safe to call multiple times. + """ + if desc_node.get("spf_metadata_injected"): + return + desc_node["spf_metadata_injected"] = True + + first_sig = next(desc_node.findall(addnodes.desc_signature), None) + if first_sig is None: + return + canon = first_sig.get("spf_canonical_name", "") + if not canon: + return + + meta = store["fixtures"].get(canon) + content_node = None + for child in desc_node.children: + if isinstance(child, addnodes.desc_content): + content_node = child + break + if content_node is None: + return + + extra_fields = nodes.field_list() + + # "Used by" field \u2014 resolved links via make_refnode + consumers = store.get("reverse_deps", {}).get(canon, []) + if consumers: + body_para = nodes.paragraph() + for i, consumer_canon in enumerate(sorted(consumers)): + short = consumer_canon.rsplit(".", 1)[-1] + obj_entry = py_domain.objects.get(consumer_canon) + if obj_entry is not None: + ref_node: nodes.Node = make_refnode( + app.builder, + docname, + obj_entry.docname, + obj_entry.node_id, + nodes.literal(short, short), + ) + else: + ref_node = nodes.literal(short, short) + body_para += ref_node + if i < len(consumers) - 1: + body_para += nodes.Text(", ") + extra_fields += nodes.field( + "", + nodes.field_name("", _FIELD_LABELS["used_by"]), + nodes.field_body("", body_para), + ) + + # "Parametrized" field — render from FixtureMeta.param_reprs tuple + if meta and meta.param_reprs: + if len(meta.param_reprs) <= 3: + # Inline for short lists + body_node: nodes.Element = nodes.paragraph() + for i, param_repr in enumerate(meta.param_reprs): + body_node += nodes.literal(param_repr, param_repr) + if i < len(meta.param_reprs) - 1: + body_node += nodes.Text(", ") + else: + # Enumerated list for longer param lists + body_node = nodes.enumerated_list(enumtype="arabic") + for param_repr in meta.param_reprs: + item = nodes.list_item() + item += nodes.paragraph("", "", nodes.literal(param_repr, param_repr)) + body_node += item + extra_fields += nodes.field( + "", + nodes.field_name("", _FIELD_LABELS["parametrized"]), + nodes.field_body("", body_node), + ) + + if extra_fields.children: + existing_list = next(content_node.findall(nodes.field_list), None) + if existing_list is not None: + for child in list(extra_fields.children): + existing_list += child + else: + content_node += extra_fields + + +def _on_doctree_resolved( + app: Sphinx, + doctree: nodes.document, + docname: str, +) -> None: + """Inject badges and metadata fields into ``py:fixture`` descriptions. + + Orchestrates three focused helpers in the correct order: + badges first, rtype stripping second, metadata injection third. + + Parameters + ---------- + app : Sphinx + The Sphinx application instance. + doctree : nodes.document + The resolved document tree. + docname : str + The name of the document being resolved. + """ + store = _get_spf_store(app.env) + py_domain: PythonDomain = app.env.get_domain("py") # type: ignore[assignment] + + for desc_node in doctree.findall(addnodes.desc): + if desc_node.get("objtype") != "fixture": + continue + + for sig_node in desc_node.findall(addnodes.desc_signature): + _inject_badges_and_reorder(sig_node) + _strip_rtype_fields(desc_node) + _inject_metadata_fields(desc_node, store, py_domain, app, docname) + + # Resolve autofixture-index placeholders + for idx_node in list(doctree.findall(autofixture_index_node)): + _resolve_fixture_index(idx_node, store, py_domain, app, docname) + + +def _visit_abbreviation_html( + self: HTML5Translator, + node: nodes.abbreviation, +) -> None: + """Emit ```` with ``tabindex`` when present. + + Sphinx's built-in ``visit_abbreviation`` only passes ``explanation`` \u2192 + ``title``. It silently drops all other node attributes (including + ``tabindex``). This override is a strict superset: non-badge abbreviation + nodes produce byte-identical output because the ``tabindex`` guard only + fires when the attribute is explicitly set. + """ + attrs: dict[str, t.Any] = {} + if node.get("explanation"): + attrs["title"] = node["explanation"] + if node.get("tabindex"): + attrs["tabindex"] = node["tabindex"] + self.body.append(self.starttag(node, "abbr", "", **attrs)) + + +def _depart_abbreviation_html( + self: HTML5Translator, + node: nodes.abbreviation, +) -> None: + """Close the ```` tag.""" + self.body.append("") diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py new file mode 100644 index 0000000..32cdb9c --- /dev/null +++ b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/_validation.py @@ -0,0 +1,96 @@ +"""Build-time fixture documentation validation with stable warning codes.""" + +from __future__ import annotations + +import typing as t + +from sphinx.util import logging as sphinx_logging + +from sphinx_autodoc_pytest_fixtures._store import FixtureStoreDict + +if t.TYPE_CHECKING: + pass + +logger = sphinx_logging.getLogger(__name__) + + +def _validate_store(store: FixtureStoreDict, app: t.Any) -> None: + """Emit structured warnings for fixture documentation issues. + + Each warning uses a stable ``spf_code`` in its ``extra`` dict so + downstream tools can filter or suppress specific checks. + + When ``pytest_fixture_lint_level`` is ``"error"``, diagnostics are + emitted at ERROR level and ``app.statuscode`` is set to ``1`` so the + Sphinx build reports failure. + + Parameters + ---------- + store : FixtureStoreDict + The finalized fixture store. + app : Any + The Sphinx application instance, or ``None`` (skips validation). + """ + if app is None: + return + + lint_level = getattr( + getattr(app, "config", None), + "pytest_fixture_lint_level", + "warning", + ) + if lint_level == "none": + return + + _emit = logger.error if lint_level == "error" else logger.warning + emitted = False + + for canon, meta in store["fixtures"].items(): + # SPF001: Missing summary/docstring + if not meta.summary: + _emit( + "fixture %r has no docstring", + meta.public_name, + extra={"fixture_canonical": canon, "spf_code": "SPF001"}, + ) + emitted = True + + # SPF002: Missing return/yield annotation + if meta.return_display in ("", "..."): + _emit( + "fixture %r has no return annotation", + meta.public_name, + extra={"fixture_canonical": canon, "spf_code": "SPF002"}, + ) + emitted = True + + # SPF003: Yield fixture missing teardown documentation + if meta.has_teardown and not meta.teardown_summary: + _emit( + "yield fixture %r has no teardown documentation", + meta.public_name, + extra={"fixture_canonical": canon, "spf_code": "SPF003"}, + ) + emitted = True + + # SPF005: Deprecated fixture missing replacement + if meta.deprecated and not meta.replacement: + _emit( + "deprecated fixture %r has no replacement specified", + meta.public_name, + extra={"fixture_canonical": canon, "spf_code": "SPF005"}, + ) + emitted = True + + # Ambiguous public names (two canonical names map to the same public name) + for pub, canon in store["public_to_canon"].items(): + if canon is None: + _emit( + "fixture public name %r is ambiguous (maps to multiple canonicals)", + pub, + extra={"spf_code": "SPF006"}, + ) + emitted = True + + if lint_level == "error" and emitted: + app.statuscode = 1 diff --git a/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/py.typed b/packages/sphinx-autodoc-pytest-fixtures/src/sphinx_autodoc_pytest_fixtures/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index b60f464..98c7736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,12 +19,14 @@ members = ["packages/*"] sphinx-fonts = { workspace = true } sphinx-gptheme = { workspace = true } sphinx-argparse-neo = { workspace = true } +sphinx-autodoc-pytest-fixtures = { workspace = true } gp-sphinx = { workspace = true } [dependency-groups] dev = [ "gp-sphinx", "sphinx-argparse-neo", + "sphinx-autodoc-pytest-fixtures", # Docs "sphinx-autobuild", # Testing @@ -67,6 +69,12 @@ files = [ module = ["sphinx_fonts", "tests.ext.test_sphinx_fonts"] disable_error_code = ["attr-defined", "unused-ignore"] +[[tool.mypy.overrides]] +# sphinx_autodoc_pytest_fixtures: not yet typed strictly; ignore_errors is temporary +# until targeted disable_error_code can replace it. +module = ["sphinx_autodoc_pytest_fixtures", "sphinx_autodoc_pytest_fixtures.*", "tests.ext.pytest_fixtures.*"] +ignore_errors = true + [tool.ruff] target-version = "py310" @@ -105,6 +113,7 @@ known-first-party = [ "sphinx_fonts", "sphinx_gptheme", "sphinx_argparse_neo", + "sphinx_autodoc_pytest_fixtures", ] combine-as-imports = true required-imports = [ @@ -117,12 +126,17 @@ convention = "numpy" [tool.ruff.lint.per-file-ignores] "*/__init__.py" = ["F401"] "packages/sphinx-argparse-neo/**/*.py" = ["E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] +"packages/sphinx-autodoc-pytest-fixtures/**/*.py" = ["D417", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] "tests/ext/argparse_neo/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] +"tests/ext/pytest_fixtures/*.py" = ["D", "E501", "UP", "A", "B", "COM", "EM", "TRY", "PERF", "RUF", "SIM", "FA100"] [tool.pytest.ini_options] -addopts = "--tb=short --no-header --showlocals --doctest-modules --ignore=packages/sphinx-argparse-neo" +addopts = "--tb=short --no-header --showlocals --doctest-modules --ignore=packages/sphinx-argparse-neo --ignore=packages/sphinx-autodoc-pytest-fixtures" doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE" +markers = [ + "integration: sphinx integration tests (require full sphinx build)", +] testpaths = [ "tests", "docs", diff --git a/tests/ext/pytest_fixtures/__init__.py b/tests/ext/pytest_fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures.py b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures.py new file mode 100644 index 0000000..2fce7bb --- /dev/null +++ b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures.py @@ -0,0 +1,1450 @@ +"""Tests for sphinx_autodoc_pytest_fixtures Sphinx extension.""" + +from __future__ import annotations + +import collections.abc +import types +import typing as t + +import pytest +from docutils import nodes + +import sphinx_autodoc_pytest_fixtures +import sphinx_autodoc_pytest_fixtures._store + +try: + import libtmux # noqa: F401 + + HAS_LIBTMUX = True +except ImportError: + HAS_LIBTMUX = False + + +class Server: + """Dummy class used as a realistic return-type annotation for test fixtures.""" + + def kill(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# _is_pytest_fixture +# --------------------------------------------------------------------------- + + +def test_is_pytest_fixture_positive() -> None: + """_is_pytest_fixture returns True for decorated fixtures.""" + + @pytest.fixture(scope="session") + def my_fixture(tmp_path_factory: pytest.TempPathFactory) -> str: + return "hello" + + assert sphinx_autodoc_pytest_fixtures._is_pytest_fixture(my_fixture) + + +def test_is_pytest_fixture_negative() -> None: + """_is_pytest_fixture returns False for plain functions.""" + + def not_a_fixture() -> str: + return "hello" + + assert not sphinx_autodoc_pytest_fixtures._is_pytest_fixture(not_a_fixture) + + +# --------------------------------------------------------------------------- +# _get_user_deps +# --------------------------------------------------------------------------- + + +def test_user_deps_filters_pytest_hidden() -> None: + """_get_user_deps excludes fixtures in PYTEST_HIDDEN (low-value noise). + + Fixtures in PYTEST_BUILTIN_LINKS (request, monkeypatch, etc.) are NOT + filtered by _get_user_deps — they are rendered with external hyperlinks + by transform_content instead. + """ + + @pytest.fixture + def my_fixture( + pytestconfig: pytest.Config, + monkeypatch: pytest.MonkeyPatch, + server: t.Any, + ) -> str: + return "hello" + + deps = sphinx_autodoc_pytest_fixtures._get_user_deps(my_fixture) + names = [name for name, _ in deps] + # pytestconfig is in PYTEST_HIDDEN → filtered + assert "pytestconfig" not in names + # monkeypatch is in PYTEST_BUILTIN_LINKS (not PYTEST_HIDDEN) → appears + assert "monkeypatch" in names + # project fixture → appears + assert "server" in names + + +def test_user_deps_empty_for_only_hidden_params() -> None: + """_get_user_deps returns empty list when all params are in PYTEST_HIDDEN.""" + + @pytest.fixture + def my_fixture(pytestconfig: pytest.Config) -> str: + return "hello" + + assert sphinx_autodoc_pytest_fixtures._get_user_deps(my_fixture) == [] + + +# --------------------------------------------------------------------------- +# _get_return_annotation — including Generator/yield unwrapping +# --------------------------------------------------------------------------- + + +def test_get_return_annotation_resolved() -> None: + """_get_return_annotation returns the resolved return type.""" + + @pytest.fixture + def my_fixture() -> str: + return "hello" + + ann = sphinx_autodoc_pytest_fixtures._get_return_annotation(my_fixture) + assert ann is str + + +def test_get_return_annotation_forward_ref_fallback() -> None: + """_get_return_annotation falls back gracefully on unresolvable forward refs.""" + + @pytest.fixture + def my_fixture() -> UnresolvableForwardRef: # type: ignore[name-defined] # noqa: F821 + return None + + # Should not raise; returns the annotation string or Parameter.empty + ann = sphinx_autodoc_pytest_fixtures._get_return_annotation(my_fixture) + assert ann is not None + + +def test_get_return_annotation_unwraps_generator() -> None: + """_get_return_annotation extracts yield type from Generator[T, None, None].""" + + @pytest.fixture + def server_fixture() -> collections.abc.Generator[Server, None, None]: + srv = Server() + yield srv + srv.kill() + + ann = sphinx_autodoc_pytest_fixtures._get_return_annotation(server_fixture) + assert ann is Server + + +def test_get_return_annotation_unwraps_iterator() -> None: + """_get_return_annotation extracts yield type from Iterator[T].""" + + @pytest.fixture + def server_fixture() -> collections.abc.Iterator[Server]: + yield Server() + + ann = sphinx_autodoc_pytest_fixtures._get_return_annotation(server_fixture) + assert ann is Server + + +# --------------------------------------------------------------------------- +# _is_factory +# --------------------------------------------------------------------------- + + +def test_factory_detection_from_type_annotation() -> None: + """_is_factory returns True for type[X] return annotation.""" + + @pytest.fixture + def test_factory(request: pytest.FixtureRequest) -> type[Server]: + return Server + + assert sphinx_autodoc_pytest_fixtures._is_factory(test_factory) + + +def test_factory_detection_from_callable_annotation() -> None: + """_is_factory returns True for Callable return annotation.""" + + @pytest.fixture + def make_thing() -> collections.abc.Callable[[], str]: + return lambda: "x" + + assert sphinx_autodoc_pytest_fixtures._is_factory(make_thing) + + +def test_factory_detection_from_name_convention() -> None: + """_is_factory returns False for unannotated (t.Any) fixtures; no name heuristic.""" + + @pytest.fixture + def CapitalFactory() -> t.Any: + return lambda: None + + assert not sphinx_autodoc_pytest_fixtures._is_factory(CapitalFactory) + + +def test_is_factory_camelcase_unannotated_defaults_to_resource() -> None: + """Unannotated CamelCase fixture must NOT be silently classified as factory.""" + + @pytest.fixture + def Session() -> t.Any: + return "string value" + + assert not sphinx_autodoc_pytest_fixtures._is_factory(Session) + + +def test_factory_detection_negative() -> None: + """_is_factory returns False for plain resource fixtures.""" + + @pytest.fixture + def plain_fixture() -> str: + return "hello" + + assert not sphinx_autodoc_pytest_fixtures._is_factory(plain_fixture) + + +# --------------------------------------------------------------------------- +# format_name (via getattr pattern used in FixtureDocumenter.format_name) +# --------------------------------------------------------------------------- + + +def test_format_name_uses_function_name_when_not_renamed() -> None: + """format_name returns the function name when no name alias is set.""" + + @pytest.fixture + def server_fixture() -> str: + return "hello" + + fixture_name = ( + getattr( + server_fixture, + "name", + None, + ) + or sphinx_autodoc_pytest_fixtures._get_fixture_fn(server_fixture).__name__ + ) + assert fixture_name == "server_fixture" + + +def test_format_name_honours_fixture_name_alias() -> None: + """format_name returns the alias when @pytest.fixture(name=...) is used.""" + + @pytest.fixture(name="server") + def _server_fixture() -> str: + return "hello" + + fixture_name = ( + getattr( + _server_fixture, + "name", + None, + ) + or sphinx_autodoc_pytest_fixtures._get_fixture_fn(_server_fixture).__name__ + ) + assert fixture_name == "server" + + +# --------------------------------------------------------------------------- +# setup() +# --------------------------------------------------------------------------- + + +def test_setup_return_value() -> None: + """setup() returns correct extension metadata.""" + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + setup_extension=lambda ext: None, + add_config_value=lambda name, default, rebuild, **kw: None, + add_crossref_type=lambda *a, **kw: None, + add_directive_to_domain=lambda d, n, cls: None, + add_role_to_domain=lambda d, n, role: None, + add_autodocumenter=lambda cls: None, + add_directive=lambda name, cls: None, + add_node=lambda *a, **kw: None, + add_css_file=lambda *a, **kw: None, + connect=lambda event, handler: connections.append((event, handler)), + ) + + result = sphinx_autodoc_pytest_fixtures.setup(app) + assert result["version"] == "1.0" + assert result["parallel_read_safe"] is True + assert result["parallel_write_safe"] is True + + +def test_setup_event_connections() -> None: + """setup() connects required event handlers.""" + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + setup_extension=lambda ext: None, + add_config_value=lambda name, default, rebuild, **kw: None, + add_crossref_type=lambda *a, **kw: None, + add_directive_to_domain=lambda d, n, cls: None, + add_role_to_domain=lambda d, n, role: None, + add_autodocumenter=lambda cls: None, + add_directive=lambda name, cls: None, + add_node=lambda *a, **kw: None, + add_css_file=lambda *a, **kw: None, + connect=lambda event, handler: connections.append((event, handler)), + ) + + sphinx_autodoc_pytest_fixtures.setup(app) + event_names = [e for e, _ in connections] + assert "missing-reference" in event_names + assert "doctree-resolved" in event_names + assert "env-purge-doc" in event_names + assert "env-merge-info" in event_names + assert "env-updated" in event_names + + handlers = dict(connections) + assert ( + handlers["missing-reference"] + is sphinx_autodoc_pytest_fixtures._on_missing_reference + ) + + +def test_setup_registers_autodocumenter() -> None: + """setup() registers FixtureDocumenter.""" + registered: list[t.Any] = [] + + app = types.SimpleNamespace( + setup_extension=lambda ext: None, + add_config_value=lambda name, default, rebuild, **kw: None, + add_crossref_type=lambda *a, **kw: None, + add_directive_to_domain=lambda d, n, cls: None, + add_role_to_domain=lambda d, n, role: None, + add_autodocumenter=lambda cls: registered.append(cls), + add_directive=lambda name, cls: None, + add_node=lambda *a, **kw: None, + add_css_file=lambda *a, **kw: None, + connect=lambda event, handler: None, + ) + + sphinx_autodoc_pytest_fixtures.setup(app) + assert sphinx_autodoc_pytest_fixtures.FixtureDocumenter in registered + + +# --------------------------------------------------------------------------- +# _get_fixture_marker — scope normalisation (Commit 1) +# --------------------------------------------------------------------------- + + +def test_get_fixture_marker_scope_is_str_for_session() -> None: + """_get_fixture_marker always returns str scope, never enum or None.""" + + @pytest.fixture(scope="session") + def my_fixture() -> str: + return "hello" + + marker = sphinx_autodoc_pytest_fixtures._get_fixture_marker(my_fixture) + assert isinstance(marker.scope, str) + assert marker.scope == "session" + + +def test_get_fixture_marker_function_scope_is_str() -> None: + """Function-scope (default) fixture returns 'function', not None.""" + + @pytest.fixture + def fn_fixture() -> str: + return "x" + + marker = sphinx_autodoc_pytest_fixtures._get_fixture_marker(fn_fixture) + assert isinstance(marker.scope, str) + assert marker.scope == "function" + + +# --------------------------------------------------------------------------- +# _iter_injectable_params — variadic filter (Commit 1) +# --------------------------------------------------------------------------- + + +def test_iter_injectable_params_skips_kwargs() -> None: + """_iter_injectable_params skips *args and **kwargs.""" + + @pytest.fixture + def fx(server: t.Any, *args: t.Any, **kwargs: t.Any) -> None: + pass + + names = [n for n, _ in sphinx_autodoc_pytest_fixtures._iter_injectable_params(fx)] + assert names == ["server"] + assert "args" not in names + assert "kwargs" not in names + + +def test_iter_injectable_params_keeps_keyword_only() -> None: + """_iter_injectable_params includes KEYWORD_ONLY params — pytest can inject them.""" + + @pytest.fixture + def fx(*, server: t.Any) -> None: + pass + + names = [n for n, _ in sphinx_autodoc_pytest_fixtures._iter_injectable_params(fx)] + assert "server" in names + + +def test_iter_injectable_params_skips_positional_only() -> None: + """_iter_injectable_params skips POSITIONAL_ONLY params (before /). + + Positional-only parameters cannot be injected by name, so they are + correctly excluded from the fixture dependency list. + """ + import textwrap + + code = textwrap.dedent(""" + import pytest + import typing as t + + @pytest.fixture + def fx(server: t.Any, /, *, session: t.Any) -> None: + pass + """) + ns: dict[str, t.Any] = {} + exec(compile(code, "", "exec"), ns) + names = [ + n for n, _ in sphinx_autodoc_pytest_fixtures._iter_injectable_params(ns["fx"]) + ] + assert names == ["session"] + assert "server" not in names + + +# --------------------------------------------------------------------------- +# _build_badge_group_node — portable inline badge nodes (Commit 4) +# --------------------------------------------------------------------------- + + +def test_build_badge_group_node_fixture_always_present() -> None: + """_build_badge_group_node always includes a FIXTURE badge child.""" + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "function", "resource", False + ) + texts = [child.astext() for child in node.children] + assert "fixture" in texts + + +def test_build_badge_group_node_no_scope_for_function() -> None: + """Function-scope produces no scope badge (absence = function-scope).""" + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "function", "resource", False + ) + classes_all = [ + c + for child in node.children + if hasattr(child, "get") + for c in child.get("classes", []) + ] + assert sphinx_autodoc_pytest_fixtures._CSS.BADGE_SCOPE not in classes_all + + +def test_build_badge_group_node_session_scope_badge() -> None: + """Session-scope produces a scope badge with class spf-scope-session.""" + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "session", "resource", False + ) + classes_all = [ + c + for child in node.children + if hasattr(child, "get") + for c in child.get("classes", []) + ] + assert sphinx_autodoc_pytest_fixtures._CSS.scope("session") in classes_all + + +def test_build_badge_group_node_override_kind() -> None: + """override_hook produces a badge with class spf-override.""" + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "function", "override_hook", False + ) + texts = [child.astext() for child in node.children] + classes_all = [ + c + for child in node.children + if hasattr(child, "get") + for c in child.get("classes", []) + ] + assert "override" in texts + assert sphinx_autodoc_pytest_fixtures._CSS.OVERRIDE in classes_all + + +def test_build_badge_group_node_autouse_replaces_kind() -> None: + """autouse=True shows AUTO badge with spf-autouse class, no kind badge.""" + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "function", "resource", True + ) + texts = [child.astext() for child in node.children] + classes_all = [ + c + for child in node.children + if hasattr(child, "get") + for c in child.get("classes", []) + ] + assert "auto" in texts + assert sphinx_autodoc_pytest_fixtures._CSS.AUTOUSE in classes_all + assert sphinx_autodoc_pytest_fixtures._CSS.BADGE_KIND not in classes_all + + +def test_build_badge_group_node_factory_session() -> None: + """Factory + session scope produces both scope and factory badges.""" + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "session", "factory", False + ) + texts = [child.astext() for child in node.children] + classes_all = [ + c + for child in node.children + if hasattr(child, "get") + for c in child.get("classes", []) + ] + assert "factory" in texts + assert sphinx_autodoc_pytest_fixtures._CSS.FACTORY in classes_all + assert sphinx_autodoc_pytest_fixtures._CSS.scope("session") in classes_all + + +def test_build_badge_group_node_has_tabindex() -> None: + """All badge abbreviation nodes have tabindex='0' for touch accessibility.""" + from docutils import nodes + + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "session", "factory", True + ) + abbreviations = [ + child for child in node.children if isinstance(child, nodes.abbreviation) + ] + assert len(abbreviations) > 0 + for abbr in abbreviations: + assert abbr.get("tabindex") == "0", ( + f"Badge {abbr.astext()!r} missing tabindex='0'" + ) + + +# --------------------------------------------------------------------------- +# _get_spf_store — store version guard +# --------------------------------------------------------------------------- + + +def test_store_version_guard_resets_stale() -> None: + """_get_spf_store resets a store with an outdated _store_version.""" + env = types.SimpleNamespace( + domaindata={ + "sphinx_autodoc_pytest_fixtures": { + "fixtures": {"old.fixture": "stale"}, + "public_to_canon": {"old": "old.fixture"}, + "reverse_deps": {}, + "_store_version": 1, + } + } + ) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + assert store["fixtures"] == {} + assert store["public_to_canon"] == {} + assert store["_store_version"] == sphinx_autodoc_pytest_fixtures._STORE_VERSION + + +def test_store_version_guard_preserves_current() -> None: + """_get_spf_store preserves a store with the current _store_version.""" + sentinel_meta = types.SimpleNamespace(docname="api", public_name="srv") + env = types.SimpleNamespace( + domaindata={ + "sphinx_autodoc_pytest_fixtures": { + "fixtures": {"mod.srv": sentinel_meta}, + "public_to_canon": {"srv": "mod.srv"}, + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + } + } + ) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + assert store["fixtures"]["mod.srv"] is sentinel_meta + + +# --------------------------------------------------------------------------- +# public_to_canon registration logic +# --------------------------------------------------------------------------- + + +def test_public_to_canon_first_registration() -> None: + """First registration stores canonical name for a public name.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + + store["public_to_canon"]["server"] = "mod_a.server" + assert store["public_to_canon"]["server"] == "mod_a.server" + + +def test_public_to_canon_ambiguous() -> None: + """Two fixtures with the same public name mark the mapping as None.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + + # Simulate what _register_fixture_meta does (corrected logic): + public_name = "server" + + # First registration + if public_name not in store["public_to_canon"]: + store["public_to_canon"][public_name] = "mod_a.server" + elif store["public_to_canon"][public_name] != "mod_a.server": + store["public_to_canon"][public_name] = None + + # Second registration with different canonical name + if public_name not in store["public_to_canon"]: + store["public_to_canon"][public_name] = "mod_b.server" + elif store["public_to_canon"][public_name] != "mod_b.server": + store["public_to_canon"][public_name] = None + + assert store["public_to_canon"]["server"] is None + + +def test_public_to_canon_idempotent() -> None: + """Registering the same fixture twice preserves the canonical name.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + + public_name = "server" + + # First registration + if public_name not in store["public_to_canon"]: + store["public_to_canon"][public_name] = "mod.server" + elif store["public_to_canon"][public_name] != "mod.server": + store["public_to_canon"][public_name] = None + + # Same fixture registered again + if public_name not in store["public_to_canon"]: + store["public_to_canon"][public_name] = "mod.server" + elif store["public_to_canon"][public_name] != "mod.server": + store["public_to_canon"][public_name] = None + + assert store["public_to_canon"]["server"] == "mod.server" + + +# --------------------------------------------------------------------------- +# _finalize_store — store finalization +# --------------------------------------------------------------------------- + + +def _make_meta( + canonical: str, + public: str, + deps: tuple[sphinx_autodoc_pytest_fixtures.FixtureDep, ...] = (), + docname: str = "api", +) -> sphinx_autodoc_pytest_fixtures.FixtureMeta: + """Build a minimal FixtureMeta for unit tests.""" + return sphinx_autodoc_pytest_fixtures.FixtureMeta( + docname=docname, + canonical_name=canonical, + public_name=public, + source_name=public, + scope="function", + autouse=False, + kind="resource", + return_display="str", + return_xref_target=None, + deps=deps, + param_reprs=(), + has_teardown=False, + is_async=False, + summary="Test fixture.", + ) + + +def test_finalize_store_forward_reference() -> None: + """_finalize_store resolves forward-reference dep targets.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + + # consumer registered before provider → dep.target is None + consumer_dep = sphinx_autodoc_pytest_fixtures.FixtureDep( + display_name="provider", kind="fixture", target=None + ) + store["fixtures"]["mod.consumer"] = _make_meta( + "mod.consumer", "consumer", deps=(consumer_dep,) + ) + store["fixtures"]["mod.provider"] = _make_meta("mod.provider", "provider") + + sphinx_autodoc_pytest_fixtures._finalize_store(store) + + resolved_dep = store["fixtures"]["mod.consumer"].deps[0] + assert resolved_dep.target == "mod.provider" + + +def test_finalize_store_empty_store() -> None: + """_finalize_store on an empty store completes without error.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + sphinx_autodoc_pytest_fixtures._finalize_store(store) + assert store["fixtures"] == {} + assert store["public_to_canon"] == {} + assert store["reverse_deps"] == {} + + +def test_finalize_store_self_dependency() -> None: + """_finalize_store skips self-edges in reverse_deps.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + + self_dep = sphinx_autodoc_pytest_fixtures.FixtureDep( + display_name="self_ref", kind="fixture", target=None + ) + store["fixtures"]["mod.self_ref"] = _make_meta( + "mod.self_ref", "self_ref", deps=(self_dep,) + ) + + sphinx_autodoc_pytest_fixtures._finalize_store(store) + + # dep.target resolves to itself, but reverse_deps should not contain self-edge + assert "mod.self_ref" not in store["reverse_deps"].get("mod.self_ref", []) + + +def test_finalize_store_ambiguous_public_name() -> None: + """_finalize_store marks ambiguous public names as None in public_to_canon.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + + store["fixtures"]["mod_a.server"] = _make_meta("mod_a.server", "server") + store["fixtures"]["mod_b.server"] = _make_meta("mod_b.server", "server") + + sphinx_autodoc_pytest_fixtures._finalize_store(store) + + assert store["public_to_canon"]["server"] is None + + +def test_finalize_store_reverse_deps() -> None: + """_finalize_store populates reverse_deps from fixture deps.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + + dep_on_server = sphinx_autodoc_pytest_fixtures.FixtureDep( + display_name="server", kind="fixture", target="mod.server" + ) + store["fixtures"]["mod.server"] = _make_meta("mod.server", "server") + store["fixtures"]["mod.client"] = _make_meta( + "mod.client", "client", deps=(dep_on_server,) + ) + + sphinx_autodoc_pytest_fixtures._finalize_store(store) + + assert "mod.client" in store["reverse_deps"]["mod.server"] + + +def test_finalize_store_parallel_merge() -> None: + """_finalize_store resolves deps after parallel worker merge.""" + # Simulate primary env with consumer, sub-env with provider + primary_env = types.SimpleNamespace(domaindata={}) + primary_store = sphinx_autodoc_pytest_fixtures._get_spf_store(primary_env) + + consumer_dep = sphinx_autodoc_pytest_fixtures.FixtureDep( + display_name="provider", kind="fixture", target=None + ) + primary_store["fixtures"]["mod.consumer"] = _make_meta( + "mod.consumer", "consumer", deps=(consumer_dep,) + ) + + # Simulate sub-env merge + sub_env = types.SimpleNamespace(domaindata={}) + sub_store = sphinx_autodoc_pytest_fixtures._get_spf_store(sub_env) + sub_store["fixtures"]["mod.provider"] = _make_meta( + "mod.provider", "provider", docname="other" + ) + + # Merge (what _on_env_merge_info does) + primary_store["fixtures"].update(sub_store["fixtures"]) + + # Finalize + sphinx_autodoc_pytest_fixtures._finalize_store(primary_store) + + resolved_dep = primary_store["fixtures"]["mod.consumer"].deps[0] + assert resolved_dep.target == "mod.provider" + assert "mod.consumer" in primary_store["reverse_deps"]["mod.provider"] + + +def test_finalize_store_stale_target_after_purge() -> None: + """_finalize_store clears stale dep targets after provider is purged.""" + env = types.SimpleNamespace(domaindata={}) + store = sphinx_autodoc_pytest_fixtures._get_spf_store(env) + + dep_on_provider = sphinx_autodoc_pytest_fixtures.FixtureDep( + display_name="provider", kind="fixture", target="mod.provider" + ) + store["fixtures"]["mod.consumer"] = _make_meta( + "mod.consumer", "consumer", deps=(dep_on_provider,) + ) + store["fixtures"]["mod.provider"] = _make_meta("mod.provider", "provider") + + # Simulate purge of provider + del store["fixtures"]["mod.provider"] + + sphinx_autodoc_pytest_fixtures._finalize_store(store) + + resolved_dep = store["fixtures"]["mod.consumer"].deps[0] + assert resolved_dep.target is None + assert "mod.provider" not in store["reverse_deps"] + + +# --------------------------------------------------------------------------- +# Badge group text separators (Commit 4) +# --------------------------------------------------------------------------- + + +def test_badge_group_node_has_text_separators() -> None: + """Badge group nodes have Text(' ') separators between badge children.""" + from docutils import nodes as docnodes + + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "session", "factory", False + ) + # Should have: scope badge, Text(" "), factory badge, Text(" "), FIXTURE badge + text_nodes = [child for child in node.children if isinstance(child, docnodes.Text)] + assert len(text_nodes) >= 2, f"Expected >=2 Text separators, got {len(text_nodes)}" + for t_node in text_nodes: + assert t_node.astext() == " " + + +# --------------------------------------------------------------------------- +# FixtureKind validation (Commit 4) +# --------------------------------------------------------------------------- + + +def test_infer_kind_custom_warning(caplog: pytest.LogCaptureFixture) -> None: + """Unknown :kind: values produce a warning during registration.""" + import logging + + env = types.SimpleNamespace( + domaindata={}, + app=types.SimpleNamespace( + config=types.SimpleNamespace( + pytest_fixture_hidden_dependencies=frozenset(), + pytest_fixture_builtin_links={}, + pytest_external_fixture_links={}, + ), + ), + ) + + @pytest.fixture + def my_fixture() -> str: + """Return a test value.""" + return "hello" + + with caplog.at_level(logging.WARNING, logger="sphinx_autodoc_pytest_fixtures"): + sphinx_autodoc_pytest_fixtures._register_fixture_meta( + env=env, + docname="api", + obj=my_fixture, + public_name="my_fixture", + source_name="my_fixture", + modname="mod", + kind="custom_weird_kind", + app=env.app, + ) + + assert any("custom_weird_kind" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# _classify_deps +# --------------------------------------------------------------------------- + + +def test_classify_deps_project_fixture() -> None: + """Non-builtin, non-hidden dep is classified as a project fixture.""" + + @pytest.fixture + def my_fixture(server: t.Any) -> str: + return "hello" + + project, builtin, hidden = sphinx_autodoc_pytest_fixtures._classify_deps( + my_fixture, None + ) + assert "server" in project + assert "server" not in builtin + assert "server" not in hidden + + +def test_classify_deps_hidden_fixture() -> None: + """Fixture depending on pytestconfig has it classified as hidden.""" + + @pytest.fixture + def my_fixture(pytestconfig: t.Any) -> str: + return "hello" + + project, _builtin, hidden = sphinx_autodoc_pytest_fixtures._classify_deps( + my_fixture, None + ) + assert "pytestconfig" in hidden + assert "pytestconfig" not in project + + +# --------------------------------------------------------------------------- +# _has_authored_example +# --------------------------------------------------------------------------- + + +def test_has_authored_example_with_rubric() -> None: + """Authored Example rubric suppresses auto-generated snippets.""" + from docutils import nodes + + content = nodes.container() + content += nodes.paragraph("", "Some intro text.") + content += nodes.rubric("", "Example") + content += nodes.literal_block("", "def test(): pass") + assert sphinx_autodoc_pytest_fixtures._has_authored_example(content) + + +def test_has_authored_example_with_doctest() -> None: + """Doctest blocks count as authored examples.""" + from docutils import nodes + + content = nodes.container() + content += nodes.doctest_block("", ">>> 1 + 1\n2") + assert sphinx_autodoc_pytest_fixtures._has_authored_example(content) + + +def test_has_authored_example_without() -> None: + """No authored examples — auto-snippet should still be generated.""" + from docutils import nodes + + content = nodes.container() + content += nodes.paragraph("", "Just a description.") + assert not sphinx_autodoc_pytest_fixtures._has_authored_example(content) + + +def test_has_authored_example_nested_not_detected() -> None: + """Nested rubrics inside admonitions are not detected (non-recursive).""" + from docutils import nodes + + content = nodes.container() + admonition = nodes.note() + admonition += nodes.rubric("", "Example") + content += admonition + assert not sphinx_autodoc_pytest_fixtures._has_authored_example(content) + + +# --------------------------------------------------------------------------- +# _build_usage_snippet +# --------------------------------------------------------------------------- + + +def test_build_usage_snippet_resource_returns_none() -> None: + """Resource fixtures return None (generic snippet suppressed).""" + result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( + "server", "Server", "resource", "function", autouse=False + ) + assert result is None + + +def test_build_usage_snippet_autouse_returns_note() -> None: + """Autouse fixtures return a nodes.note admonition.""" + from docutils import nodes + + result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( + "auto_cleanup", None, "resource", "function", autouse=True + ) + assert isinstance(result, nodes.note) + assert "No request needed" in result.astext() + + +def test_build_usage_snippet_factory_returns_literal_block() -> None: + """Factory fixtures produce a literal_block with instantiation pattern.""" + from docutils import nodes + + result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( + "TestServer", "Server", "factory", "function", autouse=False + ) + assert isinstance(result, nodes.literal_block) + text = result.astext() + assert "test_example" in text + assert "TestServer()" in text + assert ": Server" in text + + +def test_build_usage_snippet_override_hook_returns_conftest() -> None: + """Override hook fixtures produce a conftest.py snippet.""" + from docutils import nodes + + result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( + "home_user", "str", "override_hook", "function", autouse=False + ) + assert isinstance(result, nodes.literal_block) + text = result.astext() + assert "conftest.py" in text + assert "@pytest.fixture\n" in text + + +def test_build_usage_snippet_override_hook_session_scope() -> None: + """Override hook with session scope includes scope in decorator.""" + result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( + "home_user", "str", "override_hook", "session", autouse=False + ) + assert result is not None + text = result.astext() + assert 'scope="session"' in text + + +def test_build_usage_snippet_override_hook_no_return_type() -> None: + """Override hook without return type omits the arrow annotation.""" + result = sphinx_autodoc_pytest_fixtures._build_usage_snippet( + "home_user", None, "override_hook", "function", autouse=False + ) + assert result is not None + text = result.astext() + assert " -> " not in text + + +# --------------------------------------------------------------------------- +# _on_env_purge_doc +# --------------------------------------------------------------------------- + + +def test_env_purge_doc_removes_only_target() -> None: + """Purging a doc removes only that doc's fixtures from the store.""" + env = types.SimpleNamespace( + domaindata={ + "sphinx_autodoc_pytest_fixtures": { + "fixtures": { + "mod.fixture_a": sphinx_autodoc_pytest_fixtures.FixtureMeta( + docname="page_a", + canonical_name="mod.fixture_a", + public_name="fixture_a", + source_name="fixture_a", + scope="function", + autouse=False, + kind="resource", + return_display="str", + return_xref_target=None, + deps=(), + param_reprs=(), + has_teardown=False, + is_async=False, + summary="", + ), + "mod.fixture_b": sphinx_autodoc_pytest_fixtures.FixtureMeta( + docname="page_b", + canonical_name="mod.fixture_b", + public_name="fixture_b", + source_name="fixture_b", + scope="function", + autouse=False, + kind="resource", + return_display="str", + return_xref_target=None, + deps=(), + param_reprs=(), + has_teardown=False, + is_async=False, + summary="", + ), + }, + "public_to_canon": {}, + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + }, + }, + ) + app = types.SimpleNamespace() + sphinx_autodoc_pytest_fixtures._on_env_purge_doc(app, env, "page_a") + store = env.domaindata["sphinx_autodoc_pytest_fixtures"] + assert "mod.fixture_a" not in store["fixtures"] + assert "mod.fixture_b" in store["fixtures"] + + +# --------------------------------------------------------------------------- +# FixtureMeta schema evolution — deprecated/replacement/teardown_summary +# --------------------------------------------------------------------------- + + +def test_fixture_meta_new_fields_default_to_none() -> None: + """New optional fields default to None when not provided.""" + meta = _make_meta("mod.server", "server") + assert meta.deprecated is None + assert meta.replacement is None + assert meta.teardown_summary is None + + +def test_fixture_meta_new_fields_accept_values() -> None: + """New optional fields accept explicit values.""" + meta = sphinx_autodoc_pytest_fixtures.FixtureMeta( + docname="api", + canonical_name="mod.old_server", + public_name="old_server", + source_name="old_server", + scope="function", + autouse=False, + kind="resource", + return_display="Server", + return_xref_target=None, + deps=(), + param_reprs=(), + has_teardown=True, + is_async=False, + summary="Deprecated server fixture.", + deprecated="2.0", + replacement="mod.new_server", + teardown_summary="Kills the tmux server process.", + ) + assert meta.deprecated == "2.0" + assert meta.replacement == "mod.new_server" + assert meta.teardown_summary == "Kills the tmux server process." + + +# --------------------------------------------------------------------------- +# Deprecation badge rendering +# --------------------------------------------------------------------------- + + +def test_deprecated_badge_renders_at_slot_zero() -> None: + """Deprecated badge appears as leftmost badge (slot 0).""" + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "session", "resource", False, deprecated=True + ) + badges = [c for c in node.children if isinstance(c, nodes.abbreviation)] + assert len(badges) >= 2 + # First badge should be "deprecated" + assert badges[0].astext() == "deprecated" + classes_first: list[str] = badges[0].get("classes", []) + assert sphinx_autodoc_pytest_fixtures._CSS.DEPRECATED in classes_first + + +def test_deprecated_badge_absent_when_not_deprecated() -> None: + """No deprecated badge when deprecated=False (default).""" + node = sphinx_autodoc_pytest_fixtures._build_badge_group_node( + "session", "resource", False + ) + badges = [c for c in node.children if isinstance(c, nodes.abbreviation)] + texts = [b.astext() for b in badges] + assert "deprecated" not in texts + + +# --------------------------------------------------------------------------- +# Build-time validation (SPF001-SPF006) +# --------------------------------------------------------------------------- + + +def test_spf001_missing_docstring(caplog: pytest.LogCaptureFixture) -> None: + """SPF001 fires for fixtures with empty summary.""" + import logging + + from sphinx_autodoc_pytest_fixtures._validation import _validate_store + + store: sphinx_autodoc_pytest_fixtures._store.FixtureStoreDict = { + "fixtures": { + "mod.bare": _make_meta("mod.bare", "bare"), + }, + "public_to_canon": {"bare": "mod.bare"}, + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + } + # Override summary to empty + import dataclasses + + store["fixtures"]["mod.bare"] = dataclasses.replace( + store["fixtures"]["mod.bare"], summary="" + ) + + app = types.SimpleNamespace( + config=types.SimpleNamespace(pytest_fixture_lint_level="warning") + ) + with caplog.at_level( + logging.WARNING, logger="sphinx_autodoc_pytest_fixtures._validation" + ): + _validate_store(store, app) + + spf001 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF001"] + assert len(spf001) == 1 + + +def test_spf005_deprecated_without_replacement( + caplog: pytest.LogCaptureFixture, +) -> None: + """SPF005 fires for deprecated fixtures without replacement.""" + import dataclasses + import logging + + from sphinx_autodoc_pytest_fixtures._validation import _validate_store + + meta = dataclasses.replace( + _make_meta("mod.old", "old"), deprecated="2.0", replacement=None + ) + store: sphinx_autodoc_pytest_fixtures._store.FixtureStoreDict = { + "fixtures": {"mod.old": meta}, + "public_to_canon": {"old": "mod.old"}, + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + } + app = types.SimpleNamespace( + config=types.SimpleNamespace(pytest_fixture_lint_level="warning") + ) + with caplog.at_level( + logging.WARNING, logger="sphinx_autodoc_pytest_fixtures._validation" + ): + _validate_store(store, app) + + spf005 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF005"] + assert len(spf005) == 1 + + +def test_validation_silent_when_lint_level_none( + caplog: pytest.LogCaptureFixture, +) -> None: + """lint_level='none' suppresses all validation warnings.""" + import dataclasses + import logging + + from sphinx_autodoc_pytest_fixtures._validation import _validate_store + + meta = dataclasses.replace(_make_meta("mod.bare", "bare"), summary="") + store: sphinx_autodoc_pytest_fixtures._store.FixtureStoreDict = { + "fixtures": {"mod.bare": meta}, + "public_to_canon": {"bare": "mod.bare"}, + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + } + app = types.SimpleNamespace( + config=types.SimpleNamespace(pytest_fixture_lint_level="none") + ) + with caplog.at_level( + logging.WARNING, logger="sphinx_autodoc_pytest_fixtures._validation" + ): + _validate_store(store, app) + + assert len(caplog.records) == 0 + + +def test_lint_level_error_uses_logger_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """lint_level='error' emits ERROR-level records and sets statuscode=1.""" + import dataclasses + import logging + + from sphinx_autodoc_pytest_fixtures._validation import _validate_store + + meta = dataclasses.replace(_make_meta("mod.bare", "bare"), summary="") + store: sphinx_autodoc_pytest_fixtures._store.FixtureStoreDict = { + "fixtures": {"mod.bare": meta}, + "public_to_canon": {"bare": "mod.bare"}, + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + } + app = types.SimpleNamespace( + config=types.SimpleNamespace(pytest_fixture_lint_level="error"), + statuscode=0, + ) + with caplog.at_level( + logging.DEBUG, logger="sphinx_autodoc_pytest_fixtures._validation" + ): + _validate_store(store, app) + + spf001 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF001"] + assert len(spf001) == 1 + assert spf001[0].levelno == logging.ERROR + assert app.statuscode == 1 + + +def test_spf002_missing_return_annotation(caplog: pytest.LogCaptureFixture) -> None: + """SPF002 fires for fixtures with empty return annotation.""" + import dataclasses + import logging + + from sphinx_autodoc_pytest_fixtures._validation import _validate_store + + meta = dataclasses.replace(_make_meta("mod.bare", "bare"), return_display="...") + store: sphinx_autodoc_pytest_fixtures._store.FixtureStoreDict = { + "fixtures": {"mod.bare": meta}, + "public_to_canon": {"bare": "mod.bare"}, + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + } + app = types.SimpleNamespace( + config=types.SimpleNamespace(pytest_fixture_lint_level="warning") + ) + with caplog.at_level( + logging.WARNING, logger="sphinx_autodoc_pytest_fixtures._validation" + ): + _validate_store(store, app) + + spf002 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF002"] + assert len(spf002) == 1 + + +def test_spf003_yield_missing_teardown(caplog: pytest.LogCaptureFixture) -> None: + """SPF003 fires for yield fixtures without teardown documentation.""" + import dataclasses + import logging + + from sphinx_autodoc_pytest_fixtures._validation import _validate_store + + meta = dataclasses.replace( + _make_meta("mod.gen", "gen"), + has_teardown=True, + teardown_summary=None, + ) + store: sphinx_autodoc_pytest_fixtures._store.FixtureStoreDict = { + "fixtures": {"mod.gen": meta}, + "public_to_canon": {"gen": "mod.gen"}, + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + } + app = types.SimpleNamespace( + config=types.SimpleNamespace(pytest_fixture_lint_level="warning") + ) + with caplog.at_level( + logging.WARNING, logger="sphinx_autodoc_pytest_fixtures._validation" + ): + _validate_store(store, app) + + spf003 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF003"] + assert len(spf003) == 1 + + +def test_spf006_ambiguous_public_name(caplog: pytest.LogCaptureFixture) -> None: + """SPF006 fires when a public name maps to multiple canonical names.""" + import logging + + from sphinx_autodoc_pytest_fixtures._validation import _validate_store + + store: sphinx_autodoc_pytest_fixtures._store.FixtureStoreDict = { + "fixtures": { + "mod_a.server": _make_meta("mod_a.server", "server"), + "mod_b.server": _make_meta("mod_b.server", "server"), + }, + "public_to_canon": {"server": None}, # ambiguous + "reverse_deps": {}, + "_store_version": sphinx_autodoc_pytest_fixtures._STORE_VERSION, + } + app = types.SimpleNamespace( + config=types.SimpleNamespace(pytest_fixture_lint_level="warning") + ) + with caplog.at_level( + logging.WARNING, logger="sphinx_autodoc_pytest_fixtures._validation" + ): + _validate_store(store, app) + + spf006 = [r for r in caplog.records if getattr(r, "spf_code", None) == "SPF006"] + assert len(spf006) == 1 + + +# --------------------------------------------------------------------------- +# _qualify_forward_ref — TYPE_CHECKING forward-reference resolution +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not HAS_LIBTMUX, + reason="requires libtmux", +) +def test_qualify_forward_ref_resolves_type_checking_import() -> None: + """_qualify_forward_ref resolves TYPE_CHECKING imports via AST parsing.""" + from libtmux.pytest_plugin import session + + from sphinx_autodoc_pytest_fixtures._metadata import _qualify_forward_ref + + fn = sphinx_autodoc_pytest_fixtures._get_fixture_fn(session) + result = _qualify_forward_ref("Session", fn) + assert result == "libtmux.session.Session" + + +@pytest.mark.skipif( + not HAS_LIBTMUX, + reason="requires libtmux", +) +def test_qualify_forward_ref_returns_none_for_unknown() -> None: + """_qualify_forward_ref returns None for names not found in module imports.""" + from libtmux.pytest_plugin import server + + from sphinx_autodoc_pytest_fixtures._metadata import _qualify_forward_ref + + fn = sphinx_autodoc_pytest_fixtures._get_fixture_fn(server) + result = _qualify_forward_ref("NonexistentClass", fn) + assert result is None + + +def test_qualify_forward_ref_prefers_type_checking_block_over_runtime_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """TYPE_CHECKING-guarded import wins over a same-name runtime import.""" + import sys + + from sphinx_autodoc_pytest_fixtures import _metadata as spf_meta + from sphinx_autodoc_pytest_fixtures._metadata import _qualify_forward_ref + + synthetic_source = """\ +from __future__ import annotations +import typing as t +from mod_a import Foo # runtime import +if t.TYPE_CHECKING: + from mod_b import Foo # TYPE_CHECKING import — should win +""" + fake_mod = types.ModuleType("fake_qual_mod") + sys.modules["fake_qual_mod"] = fake_mod + + def fake_fn() -> Foo: # type: ignore[name-defined] # noqa: F821 + pass + + fake_fn.__module__ = "fake_qual_mod" + monkeypatch.setattr(spf_meta.inspect, "getsource", lambda _: synthetic_source) + + result = _qualify_forward_ref("Foo", fake_fn) + # Pre-fix: "mod_a.Foo" (runtime import wins via first-match AST walk) + # Post-fix: "mod_b.Foo" (TYPE_CHECKING block wins) + assert result == "mod_b.Foo" + + del sys.modules["fake_qual_mod"] + + +@pytest.mark.skipif( + not HAS_LIBTMUX, + reason="requires libtmux", +) +def test_qualify_forward_ref_no_source_returns_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_qualify_forward_ref returns None when inspect.getsource raises OSError.""" + from libtmux.pytest_plugin import session + + from sphinx_autodoc_pytest_fixtures import _metadata as spf_meta + from sphinx_autodoc_pytest_fixtures._metadata import _qualify_forward_ref + + fn = sphinx_autodoc_pytest_fixtures._get_fixture_fn(session) + monkeypatch.setattr( + spf_meta.inspect, + "getsource", + lambda _: (_ for _ in ()).throw(OSError("no source")), + ) + result = _qualify_forward_ref("Session", fn) + assert result is None + + +# --------------------------------------------------------------------------- +# _extract_summary +# --------------------------------------------------------------------------- + + +def test_extract_summary_single_sentence() -> None: + """_extract_summary returns the first sentence when it ends with a period.""" + from sphinx_autodoc_pytest_fixtures._metadata import _extract_summary + + @pytest.fixture + def my_fix() -> str: + """Return a string. This second sentence should be excluded.""" + return "x" + + assert _extract_summary(my_fix) == "Return a string." + + +def test_extract_summary_sentence_at_eof() -> None: + """_extract_summary handles first paragraph whose last sentence ends at EOF.""" + from sphinx_autodoc_pytest_fixtures._metadata import _extract_summary + + @pytest.fixture + def my_fix() -> str: + """First sentence. Second sentence ends here.""" + return "x" + + # The last sentence ends at EOF with no trailing whitespace — regex must match. + assert _extract_summary(my_fix) == "First sentence." + + +def test_extract_summary_no_sentence_terminator() -> None: + """_extract_summary falls back to first_para when no sentence terminator exists.""" + from sphinx_autodoc_pytest_fixtures._metadata import _extract_summary + + @pytest.fixture + def my_fix() -> str: + """A fixture with no sentence terminator""" # noqa: D400, D401 + return "x" + + assert _extract_summary(my_fix) == "A fixture with no sentence terminator" diff --git a/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py new file mode 100644 index 0000000..adf42d2 --- /dev/null +++ b/tests/ext/pytest_fixtures/test_sphinx_pytest_fixtures_integration.py @@ -0,0 +1,1854 @@ +"""Integration tests for sphinx_autodoc_pytest_fixtures using a full Sphinx build. + +These tests build a minimal Sphinx project with a synthetic fixture module so +results are independent of the libtmux fixture signatures. They gate the B1, +B2, and B4/B5 fixes in subsequent commits. + +Run integration tests specifically: + + uv run pytest tests/docs/_ext/test_sphinx_autodoc_pytest_fixtures_integration.py -v + +""" + +from __future__ import annotations + +import io +import pathlib +import sys +import textwrap +import typing as t + +import pytest + +import sphinx_autodoc_pytest_fixtures +from sphinx_autodoc_pytest_fixtures import _CSS + +# --------------------------------------------------------------------------- +# Synthetic fixture module written to tmp_path for each test run +# --------------------------------------------------------------------------- + +FIXTURE_MOD_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + import typing as t + import pytest + + class Server: + \"\"\"A fake server.\"\"\" + + @pytest.fixture(scope="session") + def my_server() -> Server: + \"\"\"Return a fake server for testing. + + Use this when you need a long-lived server across the session. + \"\"\" + return Server() + + @pytest.fixture + def my_client(my_server: Server) -> str: + \"\"\"Return a fake client connected to *my_server*.\"\"\" + return f"client@{my_server}" + + @pytest.fixture + def home_user() -> str: + \"\"\"Override to customise the home directory username.\"\"\" + return "testuser" + + @pytest.fixture + def yield_server(my_server: Server) -> t.Generator[Server, None, None]: + \"\"\"Yield the server and tear down after the test.\"\"\" + yield my_server + + @pytest.fixture(autouse=True) + def auto_cleanup() -> None: + \"\"\"Runs automatically before every test — no request needed.\"\"\" + + @pytest.fixture + def TestServer() -> type[Server]: + \"\"\"Return the Server class for direct instantiation (factory fixture).\"\"\" + return Server + + @pytest.fixture(name="renamed_fixture") + def _internal_name() -> str: + \"\"\"Fixture with a name alias — injected as 'renamed_fixture'.\"\"\" + return "renamed" + """, +) + +CONF_PY_TEMPLATE = """\ +import sys +sys.path.insert(0, "{srcdir}") + +extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_pytest_fixtures", +] + +master_doc = "index" +exclude_patterns = ["_build"] +html_theme = "alabaster" +""" + +INDEX_RST = textwrap.dedent( + """\ + Test fixtures + ============= + + .. py:module:: fixture_mod + + .. autofixture:: fixture_mod.my_server + + .. autofixture:: fixture_mod.my_client + + .. autofixture:: fixture_mod.home_user + :kind: override_hook + + .. autofixture:: fixture_mod.yield_server + + .. autofixture:: fixture_mod.auto_cleanup + + .. autofixture:: fixture_mod.TestServer + + .. autofixture:: fixture_mod._internal_name + """, +) + + +# --------------------------------------------------------------------------- +# Module isolation helper +# --------------------------------------------------------------------------- + + +def _purge_fixture_module(name: str = "fixture_mod") -> None: + """Remove *name* and its sub-modules from sys.modules. + + Multiple Sphinx builds in the same process cache imported modules. + Without this cleanup, the second test to use ``fixture_mod`` gets the + first test's cached version — new attributes written to a fresh + ``fixture_mod.py`` in a different ``tmp_path`` are never visible. + """ + for key in list(sys.modules): + if key == name or key.startswith(f"{name}."): + del sys.modules[key] + + +# --------------------------------------------------------------------------- +# Shared fixture: build the Sphinx app once per test (no caching — each test +# gets an isolated tmp_path) +# --------------------------------------------------------------------------- + + +class _SphinxResult(t.NamedTuple): + """Lightweight wrapper around a completed Sphinx build.""" + + app: t.Any # sphinx.application.Sphinx + srcdir: pathlib.Path + outdir: pathlib.Path + status: str + warnings: str + + +def _build_sphinx_app( + tmp_path: pathlib.Path, + *, + confoverrides: dict[str, t.Any] | None = None, + fixture_source: str | None = None, + index_rst: str | None = None, +) -> _SphinxResult: + """Write project files and run a full Sphinx HTML build; return results. + + Parameters + ---------- + tmp_path : + Per-test temporary directory provided by pytest. + confoverrides : + Optional Sphinx confoverrides dict (passed to Sphinx constructor). + fixture_source : + Override the fixture module source written to ``fixture_mod.py``. + Defaults to :data:`FIXTURE_MOD_SOURCE`. + index_rst : + Override the RST index written to ``index.rst``. + Defaults to :data:`INDEX_RST`. + """ + from sphinx.application import Sphinx + + srcdir = tmp_path / "src" + outdir = tmp_path / "out" + doctreedir = tmp_path / ".doctrees" + + srcdir.mkdir() + outdir.mkdir() + doctreedir.mkdir() + + (srcdir / "fixture_mod.py").write_text( + fixture_source if fixture_source is not None else FIXTURE_MOD_SOURCE, + encoding="utf-8", + ) + (srcdir / "conf.py").write_text( + CONF_PY_TEMPLATE.format(srcdir=str(srcdir)), + encoding="utf-8", + ) + (srcdir / "index.rst").write_text( + index_rst if index_rst is not None else INDEX_RST, + encoding="utf-8", + ) + + status_buf = io.StringIO() + warning_buf = io.StringIO() + + _purge_fixture_module() + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + confoverrides=confoverrides, + status=status_buf, + warning=warning_buf, + freshenv=True, + ) + app.build() + + return _SphinxResult( + app=app, + srcdir=srcdir, + outdir=outdir, + status=status_buf.getvalue(), + warnings=warning_buf.getvalue(), + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_fixture_target_id(tmp_path: pathlib.Path) -> None: + """Registered fixtures have non-empty IDs in their signature nodes.""" + from sphinx.domains.python import PythonDomain + + result = _build_sphinx_app(tmp_path) + domain = t.cast("PythonDomain", result.app.env.get_domain("py")) + objects = domain.data["objects"] + # ObjectEntry = (docname, node_id, objtype, aliased) + fixture_keys = [k for k, v in objects.items() if v.objtype == "fixture"] + assert fixture_keys, f"No py:fixture objects found in domain. Objects: {objects}" + + +@pytest.mark.integration +def test_fixture_in_domain_objects(tmp_path: pathlib.Path) -> None: + """Domain objects registry has qualified fixture names with objtype='fixture'.""" + from sphinx.domains.python import PythonDomain + + result = _build_sphinx_app(tmp_path) + domain = t.cast("PythonDomain", result.app.env.get_domain("py")) + objects = domain.data["objects"] + + assert "fixture_mod.my_server" in objects, ( + f"fixture_mod.my_server not in domain objects. Keys: {list(objects)}" + ) + # ObjectEntry = (docname, node_id, objtype, aliased) + assert objects["fixture_mod.my_server"].objtype == "fixture", ( + f"Expected objtype='fixture', got {objects['fixture_mod.my_server'].objtype!r}" + ) + + +@pytest.mark.integration +def test_fixture_in_inventory(tmp_path: pathlib.Path) -> None: + """objects.inv contains a 'py:fixture' section with the registered fixtures.""" + from sphinx.util.inventory import InventoryFile + + result = _build_sphinx_app(tmp_path) + inv_path = result.outdir / "objects.inv" + assert inv_path.exists(), "objects.inv was not generated" + + inv = InventoryFile.loads(inv_path.read_bytes(), uri="") + # inv.data is dict[obj_type_str, dict[name_str, _InventoryItem]] + assert "py:fixture" in inv.data, ( + f"'py:fixture' not in inventory. Types: {sorted(inv.data)}" + ) + fixture_names = list(inv.data["py:fixture"]) + assert any("my_server" in name for name in fixture_names), ( + f"my_server not in py:fixture inventory entries: {fixture_names}" + ) + + +@pytest.mark.integration +def test_manual_directive_without_module(tmp_path: pathlib.Path) -> None: + """Manual py:fixture without currentmodule uses bare name as target.""" + from sphinx.application import Sphinx + from sphinx.domains.python import PythonDomain + + srcdir = tmp_path / "src" + outdir = tmp_path / "out" + doctreedir = tmp_path / ".doctrees" + srcdir.mkdir() + outdir.mkdir() + 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", + ) + # Bare directive with no currentmodule + (srcdir / "index.rst").write_text( + "Manual\n======\n\n.. py:fixture:: bare_server\n\n Bare server docs.\n", + encoding="utf-8", + ) + + _purge_fixture_module() + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + status=io.StringIO(), + warning=io.StringIO(), + freshenv=True, + ) + app.build() + + domain = t.cast("PythonDomain", app.env.get_domain("py")) + objects = domain.data["objects"] + # Without currentmodule the target is the bare name — document this known behaviour + assert "bare_server" in objects, ( + "Known limitation: bare py:fixture registers under unqualified name. " + f"Objects: {list(objects)}" + ) + + +@pytest.mark.integration +def test_xref_resolves(tmp_path: pathlib.Path) -> None: + """Cross-file :fixture: role resolves to a hyperlink in the output HTML.""" + from sphinx.application import Sphinx + + srcdir = tmp_path / "src" + outdir = tmp_path / "out" + doctreedir = tmp_path / ".doctrees" + srcdir.mkdir() + outdir.mkdir() + 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 / "index.rst").write_text( + textwrap.dedent( + """\ + Fixtures + ======== + + .. toctree:: + + api + usage + + """, + ), + encoding="utf-8", + ) + (srcdir / "api.rst").write_text( + textwrap.dedent( + """\ + API + === + + .. py:module:: fixture_mod + + .. autofixture:: fixture_mod.my_server + """, + ), + encoding="utf-8", + ) + (srcdir / "usage.rst").write_text( + textwrap.dedent( + """\ + Usage + ===== + + Use :fixture:`fixture_mod.my_server` to get a server. + """, + ), + encoding="utf-8", + ) + + warning_buf = io.StringIO() + _purge_fixture_module() + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + status=io.StringIO(), + warning=warning_buf, + freshenv=True, + ) + app.build() + + usage_html = (outdir / "usage.html").read_text(encoding="utf-8") + # The xref should produce a hyperlink element pointing to the fixture + assert " None: + """Scope value appears in the rendered HTML for autofixture directives.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # my_server has scope="session"; the rendered page should mention it + assert "session" in index_html, ( + "Expected scope 'session' to appear in rendered HTML for my_server" + ) + + +@pytest.mark.integration +def test_config_hidden_deps(tmp_path: pathlib.Path) -> None: + """pytest_fixture_hidden_dependencies suppresses named deps from output HTML.""" + # my_client depends on my_server; hiding my_server should suppress it + result = _build_sphinx_app( + tmp_path, + confoverrides={ + "pytest_fixture_hidden_dependencies": frozenset( + {*sphinx_autodoc_pytest_fixtures.PYTEST_HIDDEN, "my_server"}, + ), + }, + ) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # With my_server hidden, it must not appear in "Depends on" for my_client + # (it may still appear as its own fixture entry — check the Depends section) + assert ( + "Depends on" not in index_html + or "my_server" + not in index_html.split( + "Depends on", + )[-1].split(" None: + """Pytest builtin deps in PYTEST_BUILTIN_LINKS render with an external URL.""" + # Add a synthetic fixture that depends on tmp_path (a builtin with external link) + src = FIXTURE_MOD_SOURCE + textwrap.dedent( + """\ + + @pytest.fixture + def needs_tmp(tmp_path: "pathlib.Path") -> str: + \"\"\"Uses tmp_path internally.\"\"\" + return str(tmp_path) + """, + ) + srcdir = tmp_path / "src" + outdir = tmp_path / "out" + doctreedir = tmp_path / ".doctrees" + srcdir.mkdir() + outdir.mkdir() + 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 / "index.rst").write_text( + "Fixtures\n========\n\n.. py:module:: fixture_mod\n\n" + ".. autofixture:: fixture_mod.needs_tmp\n", + encoding="utf-8", + ) + + assert "tmp_path" in sphinx_autodoc_pytest_fixtures.PYTEST_BUILTIN_LINKS, ( + "tmp_path must be in PYTEST_BUILTIN_LINKS for this test to be meaningful" + ) + + from sphinx.application import Sphinx + + _purge_fixture_module() + sphinx_app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + status=io.StringIO(), + warning=io.StringIO(), + freshenv=True, + ) + sphinx_app.build() + + index_html = (outdir / "index.html").read_text(encoding="utf-8") + # tmp_path dependency should be rendered with an external href + assert "tmp_path" in index_html, ( + "tmp_path dependency should appear in rendered HTML" + ) + + +@pytest.mark.integration +def test_kind_override_hook_option(tmp_path: pathlib.Path) -> None: + """Manual :kind: override_hook option appears in rendered HTML.""" + srcdir = tmp_path / "src" + outdir = tmp_path / "out" + doctreedir = tmp_path / ".doctrees" + srcdir.mkdir() + outdir.mkdir() + 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 / "index.rst").write_text( + textwrap.dedent( + """\ + Fixtures + ======== + + .. py:module:: fixture_mod + + .. py:fixture:: home_user + :kind: override_hook + + Override the home username. + """, + ), + encoding="utf-8", + ) + + from sphinx.application import Sphinx + + _purge_fixture_module() + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + status=io.StringIO(), + warning=io.StringIO(), + freshenv=True, + ) + app.build() + + index_html = (outdir / "index.html").read_text(encoding="utf-8") + # Standard kinds (override_hook) are communicated via badge, not Kind field. + assert _CSS.OVERRIDE in index_html, ( + "Expected spf-override badge class when :kind: override_hook is set" + ) + + +@pytest.mark.integration +def test_usage_none_suppresses_snippet(tmp_path: pathlib.Path) -> None: + """:usage: none suppresses the auto-generated usage snippet.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. py:fixture:: my_server + :usage: none + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # No usage snippet should be generated — no code-block with def test_example + assert "def test_example" not in index_html + assert "conftest.py" not in index_html + + +@pytest.mark.integration +def test_async_fixture_callout_renders(tmp_path: pathlib.Path) -> None: + """Async fixtures show the 'async fixture' callout note via autofixture::.""" + async_fixture_source = textwrap.dedent( + """\ + from __future__ import annotations + import pytest + + @pytest.fixture + async def async_resource() -> str: + \"\"\"An async fixture.\"\"\" + return "async_value" + """, + ) + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture:: fixture_mod.async_resource + """, + ) + result = _build_sphinx_app( + tmp_path, + fixture_source=async_fixture_source, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # The async callout message should appear in the rendered HTML + assert "async fixture" in index_html.lower() + + +@pytest.mark.integration +def test_spf003_fires_for_yield_fixture_via_autodoc(tmp_path: pathlib.Path) -> None: + """SPF003 fires for yield fixtures with no Teardown docstring section via autodoc. + + The fixture intentionally has no "Teardown" section in its docstring so this + test remains stable after the teardown-extraction feature (commit C4) is added. + """ + yield_fixture_source = textwrap.dedent( + """\ + from __future__ import annotations + import typing as t + import pytest + + @pytest.fixture + def simple_yield() -> t.Generator[str, None, None]: + \"\"\"A yield fixture with no teardown documentation.\"\"\" + yield "value" + # teardown happens here but is not documented + """, + ) + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture:: fixture_mod.simple_yield + """, + ) + result = _build_sphinx_app( + tmp_path, + fixture_source=yield_fixture_source, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "warning"}, + ) + # SPF003 fires: yield fixture with no teardown documentation + assert "no teardown documentation" in result.warnings + + +@pytest.mark.integration +def test_teardown_section_suppresses_spf003(tmp_path: pathlib.Path) -> None: + """A 'Teardown' docstring section suppresses SPF003 and shows teardown-summary.""" + teardown_fixture_source = textwrap.dedent( + """\ + from __future__ import annotations + import typing as t + import pytest + + @pytest.fixture + def documented_yield() -> t.Generator[str, None, None]: + \"\"\"A yield fixture with documented teardown. + + Teardown + -------- + Releases the resource after the test completes. + \"\"\" + yield "value" + """, + ) + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture:: fixture_mod.documented_yield + """, + ) + result = _build_sphinx_app( + tmp_path, + fixture_source=teardown_fixture_source, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "warning"}, + ) + # SPF003 must NOT fire when there is a Teardown section + assert "no teardown documentation" not in result.warnings + # The teardown summary text must appear in the rendered HTML + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "Releases the resource" in index_html + + +@pytest.mark.integration +def test_override_hook_snippet_shows_conftest(tmp_path: pathlib.Path) -> None: + """override_hook fixtures show a conftest.py snippet, not def test_example.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # home_user is classified as override_hook via explicit :kind: option — + # its usage snippet must show conftest.py, not test. + assert "conftest.py" in index_html, ( + "Expected conftest.py in override_hook fixture usage snippet (home_user)" + ) + + +@pytest.mark.integration +def test_function_scope_field_suppressed(tmp_path: pathlib.Path) -> None: + """Function-scope fixtures do not render a 'Scope:' metadata field.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # my_client is function-scope; "Scope" field should be absent from its entry. + # my_server is session-scope and WILL have "Scope" — check that function-scope + # entries between my_client headings do not contain "Scope: function". + # Simple check: "function" should not appear as a scope value anywhere. + assert "Scope: function" not in index_html, ( + "Function-scope fixture should not render 'Scope: function' field" + ) + + +@pytest.mark.integration +def test_badge_group_present_in_html(tmp_path: pathlib.Path) -> None: + """Every fixture signature contains a spf-badge-group span.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert _CSS.BADGE_GROUP in index_html, ( + "Expected spf-badge-group to be present in rendered HTML" + ) + assert _CSS.BADGE_FIXTURE in index_html, ( + "Expected FIXTURE badge (spf-badge--fixture) to be present in rendered HTML" + ) + + +@pytest.mark.integration +def test_scope_badge_session_present(tmp_path: pathlib.Path) -> None: + """Session-scope fixtures have a scope badge with class spf-scope-session.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert _CSS.scope("session") in index_html, ( + "Expected spf-scope-session class badge for my_server (scope=session)" + ) + + +@pytest.mark.integration +def test_no_scope_badge_for_function_scope(tmp_path: pathlib.Path) -> None: + """Function-scope fixtures do not have a scope badge in the HTML.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert _CSS.scope("function") not in index_html, ( + "Function-scope fixtures should not render a scope badge" + ) + + +@pytest.mark.integration +def test_session_scope_lifecycle_note_present(tmp_path: pathlib.Path) -> None: + """Session-scope fixtures have a lifecycle callout note in HTML.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "once per test session" in index_html, ( + "Expected session-scope lifecycle note for my_server (scope=session)" + ) + + +@pytest.mark.integration +def test_no_build_warnings(tmp_path: pathlib.Path) -> None: + """A full build of the synthetic fixture module produces zero WARNING lines.""" + result = _build_sphinx_app( + tmp_path, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + warnings = result.warnings + # Strip ANSI escape codes before filtering + import re + + ansi_escape = re.compile(r"\x1b\[[0-9;]*m") + warning_lines = [ + line + for line in warnings.splitlines() + if "WARNING" in line + # Sphinx emits "already registered" warnings when multiple Sphinx apps + # run in the same process — these are internal Sphinx artefacts, not + # problems with our extension. + and "already registered" not in line + # Filter Sphinx theme warnings unrelated to fixture processing + and "alabaster" not in line + ] + # Strip ANSI codes for readability in failure output + warning_lines = [ansi_escape.sub("", line) for line in warning_lines] + assert not warning_lines, "Unexpected WARNING lines in build output:\n" + "\n".join( + warning_lines + ) + + +@pytest.mark.integration +def test_lint_level_error_fails_build(tmp_path: pathlib.Path) -> None: + """lint_level='error' causes the build to report failure on validation issues.""" + # auto_cleanup in FIXTURE_MOD_SOURCE has no return annotation → SPF002 fires. + result = _build_sphinx_app( + tmp_path, + confoverrides={"pytest_fixture_lint_level": "error"}, + ) + assert result.app.statuscode != 0, ( + "Expected non-zero statuscode with lint_level='error' " + "and fixtures that trigger validation warnings" + ) + + +@pytest.mark.integration +def test_factory_snippet_shows_instantiation(tmp_path: pathlib.Path) -> None: + """Factory fixtures are classified as factory and render a FACTORY badge.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # TestServer is a factory fixture — it must have the FACTORY badge. + assert _CSS.FACTORY in index_html, ( + "Expected spf-factory class badge for TestServer factory fixture" + ) + # Standard kinds (resource, factory, override_hook) are communicated via + # badges only — the Kind field is suppressed for badge-covered kinds. + assert "

factory

" not in index_html, ( + "Standard Kind field should be suppressed when badge covers it" + ) + + +@pytest.mark.integration +def test_autouse_note_present(tmp_path: pathlib.Path) -> None: + """Autouse fixtures show a 'No request needed' note instead of a test snippet.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "No request needed" in index_html, ( + "Expected 'No request needed' note for auto_cleanup (autouse=True)" + ) + + +@pytest.mark.integration +def test_name_alias_registered_in_domain(tmp_path: pathlib.Path) -> None: + """Fixtures with name= alias are registered under the alias, not internal name.""" + from sphinx.domains.python import PythonDomain + + result = _build_sphinx_app(tmp_path) + domain = t.cast("PythonDomain", result.app.env.get_domain("py")) + objects = domain.data["objects"] + fixture_keys = {k for k, v in objects.items() if v.objtype == "fixture"} + assert "fixture_mod.renamed_fixture" in fixture_keys, ( + f"Expected 'fixture_mod.renamed_fixture' in domain objects. " + f"Fixture keys: {fixture_keys}" + ) + assert "fixture_mod._internal_name" not in fixture_keys, ( + "Internal function name '_internal_name' should not appear in domain — " + "only the 'renamed_fixture' alias should be registered" + ) + + +# --------------------------------------------------------------------------- +# "Used by" and "Parametrized" metadata rendering (Commit 3) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_used_by_links_rendered(tmp_path: pathlib.Path) -> None: + """Fixtures with consumers show a "Used by" field with anchor links.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # my_server is used by my_client and yield_server — assert anchor markup, + # not just text presence (plain text would pass even if xref resolution failed) + assert "Used by" in index_html + assert '
None: + """Fixtures with no consumers do not show "Used by".""" + result = _build_sphinx_app(tmp_path) + store = result.app.env.domaindata.get("sphinx_autodoc_pytest_fixtures", {}) + reverse_deps = store.get("reverse_deps", {}) + assert "fixture_mod.auto_cleanup" not in reverse_deps + + +@pytest.mark.integration +def test_parametrized_values_rendered(tmp_path: pathlib.Path) -> None: + """Parametrized fixtures show their parameter values.""" + extra_fixture = textwrap.dedent( + """\ + + @pytest.fixture(params=["bash", "zsh"]) + def shell(request) -> str: + \"\"\"Fixture parametrized over shell interpreters.\"\"\" + return request.param + """, + ) + result = _build_sphinx_app( + tmp_path, + fixture_source=FIXTURE_MOD_SOURCE + extra_fixture, + index_rst=INDEX_RST + "\n.. autofixture:: fixture_mod.shell\n", + ) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "Parametrized" in index_html + assert "'bash'" in index_html + assert "'zsh'" in index_html + + +@pytest.mark.integration +def test_manual_fixture_params_renders_parametrized_field( + tmp_path: pathlib.Path, +) -> None: + """py:fixture:: :params: option renders a Parametrized field.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. py:fixture:: shell + :params: 'bash', 'zsh' + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "Parametrized" in index_html + assert "'bash'" in index_html + assert "'zsh'" in index_html + + +# --------------------------------------------------------------------------- +# Short-name :fixture: xref resolution (Commit 3) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_short_name_fixture_xref_resolves(tmp_path: pathlib.Path) -> None: + """:fixture:`my_server` short-name reference resolves to a hyperlink.""" + index_with_xref = INDEX_RST + textwrap.dedent( + """\ + + Usage + ----- + + See :fixture:`my_server` for the server fixture. + """, + ) + result = _build_sphinx_app(tmp_path, index_rst=index_with_xref) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # Must resolve to a real link — plain text or pending_xref would still + # contain the fixture name but would NOT produce an element. + assert ' None: + """Manual .. py:fixture:: directives register in the env store.""" + manual_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. py:module:: fixture_mod + + .. autofixture:: fixture_mod.my_server + + .. py:fixture:: manual_helper + :scope: session + :depends: my_server + + A manually documented fixture. + """, + ) + result = _build_sphinx_app(tmp_path, index_rst=manual_rst) + store = result.app.env.domaindata.get("sphinx_autodoc_pytest_fixtures", {}) + fixtures = store.get("fixtures", {}) + assert "fixture_mod.manual_helper" in fixtures + meta = fixtures["fixture_mod.manual_helper"] + assert meta.scope == "session" + assert len(meta.deps) == 1 + assert meta.deps[0].display_name == "my_server" + + +# --------------------------------------------------------------------------- +# CSS contract tests — verify extension HTML matches custom.css selectors +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_css_contract_badge_classes(tmp_path: pathlib.Path) -> None: + """CSS class names used in custom.css are present in rendered HTML.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + + # These classes are targeted by selectors in docs/_static/css/custom.css. + # If the extension changes its class names, CSS silently breaks. + css_classes = [ + _CSS.BADGE_GROUP, + _CSS.BADGE, + _CSS.BADGE_FIXTURE, + _CSS.BADGE_SCOPE, + _CSS.scope("session"), + _CSS.BADGE_KIND, + _CSS.FACTORY, + ] + for cls in css_classes: + assert cls in index_html, f"CSS class {cls!r} missing from rendered HTML" + + +@pytest.mark.integration +def test_badge_tabindex_in_html(tmp_path: pathlib.Path) -> None: + """Badges render with tabindex='0' for touch/keyboard accessibility.""" + result = _build_sphinx_app(tmp_path) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert 'tabindex="0"' in index_html, ( + "Expected tabindex='0' on badge elements for touch accessibility" + ) + + +# --------------------------------------------------------------------------- +# autofixture-index directive +# --------------------------------------------------------------------------- + + +def _extract_index_table(html: str) -> str: + """Extract the spf-fixture-index table fragment from rendered HTML. + + The built page contains both the index table and fixture cards. Both emit + badge HTML, so whole-page assertions get false positives from card badges. + Always extract the table fragment before asserting on Flags badge content. + + Parameters + ---------- + html : str + Full rendered HTML of the built index page. + + Returns + ------- + str + Substring from the opening ```` tag. + """ + marker = _CSS.FIXTURE_INDEX + pos = 0 + while True: + start = html.find("", start) + if marker in html[start : end_tag + 1]: + break + pos = start + 1 + close = html.find("", start) + assert close != -1, "Unclosed in HTML" + return html[start : close + len("
")] + + +@pytest.mark.integration +def test_autofixture_index_renders_table(tmp_path: pathlib.Path) -> None: + """autofixture-index directive produces a table with linked fixture names.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + + .. autofixture:: fixture_mod.my_server + + .. autofixture:: fixture_mod.my_client + + .. autofixture:: fixture_mod.TestServer + """, + ) + result = _build_sphinx_app(tmp_path, index_rst=index_rst) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + + # Table should have the spf-fixture-index class + assert _CSS.FIXTURE_INDEX in index_html + + # Fixture names should be cross-ref links (not plain text) + assert 'href="#fixture_mod.my_server"' in index_html or "my_server" in index_html + assert "TestServer" in index_html + + # Column headers: 4-column Flags layout (Scope/Kind columns removed) + assert ">Flags<" in index_html + assert ">Scope<" not in index_html + assert ">Kind<" not in index_html + # Scope and kind appear as badge CSS classes in the Flags column table fragment + table_html = _extract_index_table(index_html) + assert _CSS.scope("session") in table_html # my_server: session-scope badge + assert _CSS.FACTORY in table_html # TestServer: factory-kind badge + assert _CSS.BADGE_FIXTURE in table_html # FIXTURE badge shown in every table row + + +@pytest.mark.integration +def test_autofixture_index_description_renders_markup( + tmp_path: pathlib.Path, +) -> None: + """autofixture-index description column renders RST markup, not raw text.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + + .. autofixture:: fixture_mod.my_server + """, + ) + result = _build_sphinx_app(tmp_path, index_rst=index_rst) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + + # Description should NOT contain raw RST markup like :class: or `` + # (the my_server docstring is plain text so just verify no RST leaks) + assert _CSS.FIXTURE_INDEX in index_html + # The description "Return a fake server for testing." should appear + assert "fake server" in index_html + + +# --------------------------------------------------------------------------- +# Flags column badge regression tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_autofixture_index_flags_session_scope(tmp_path: pathlib.Path) -> None: + """Flags column shows session-scope badge and FIXTURE badge.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + + .. autofixture:: fixture_mod.my_server + """, + ) + result = _build_sphinx_app(tmp_path, index_rst=index_rst) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + table_html = _extract_index_table(index_html) + assert _CSS.scope("session") in table_html + assert _CSS.BADGE_FIXTURE in table_html + + +@pytest.mark.integration +def test_autofixture_index_flags_factory_kind(tmp_path: pathlib.Path) -> None: + """Flags column shows factory-kind badge for a factory fixture.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + + .. autofixture:: fixture_mod.TestServer + """, + ) + result = _build_sphinx_app(tmp_path, index_rst=index_rst) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + table_html = _extract_index_table(index_html) + assert _CSS.FACTORY in table_html + + +@pytest.mark.integration +def test_autofixture_index_flags_autouse(tmp_path: pathlib.Path) -> None: + """Flags column shows autouse badge for autouse=True fixtures.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + + .. autofixture:: fixture_mod.auto_cleanup + """, + ) + result = _build_sphinx_app(tmp_path, index_rst=index_rst) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + table_html = _extract_index_table(index_html) + assert _CSS.AUTOUSE in table_html + + +@pytest.mark.integration +def test_autofixture_index_flags_empty_for_defaults(tmp_path: pathlib.Path) -> None: + """Flags cell shows only FIXTURE badge for a plain function-scope fixture. + + A default fixture (function scope, resource kind, not autouse) shows FIXTURE + and nothing else — no scope, kind, autouse, deprecated, or override badges. + """ + fixture_source = textwrap.dedent( + """\ + from __future__ import annotations + import pytest + + @pytest.fixture + def plain_fixture() -> str: + \"\"\"A plain function-scope resource fixture.\"\"\" + return "plain" + """, + ) + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + + .. autofixture:: fixture_mod.plain_fixture + """, + ) + result = _build_sphinx_app( + tmp_path, + fixture_source=fixture_source, + index_rst=index_rst, + ) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + table_html = _extract_index_table(index_html) + # FIXTURE badge always present; no scope/kind/autouse badges for default fixture + assert _CSS.BADGE_FIXTURE in table_html + assert _CSS.BADGE_SCOPE not in table_html + assert _CSS.AUTOUSE not in table_html + assert _CSS.FACTORY not in table_html + assert _CSS.OVERRIDE not in table_html + + +@pytest.mark.integration +def test_autofixture_index_flags_deprecated(tmp_path: pathlib.Path) -> None: + """Flags column shows deprecated badge when :deprecated: RST option is set. + + FixtureMeta.deprecated is sourced from the RST directive :deprecated: option. + autofixture (autodoc) does not support :deprecated:; use py:fixture instead. + """ + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + + .. py:fixture:: my_client + :deprecated: 1.5 + """, + ) + result = _build_sphinx_app(tmp_path, index_rst=index_rst) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + table_html = _extract_index_table(index_html) + assert _CSS.DEPRECATED in table_html + + +# --------------------------------------------------------------------------- +# autofixture-index :exclude: HTML rendering +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_autofixture_index_exclude_hides_row(tmp_path: pathlib.Path) -> None: + """autofixture-index :exclude: removes the named fixture from the table HTML.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + :exclude: my_client, auto_cleanup + + .. autofixture:: fixture_mod.my_server + + .. autofixture:: fixture_mod.my_client + + .. autofixture:: fixture_mod.auto_cleanup + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + table_html = _extract_index_table(index_html) + # Excluded fixtures must not appear in the rendered table + assert "my_client" not in table_html + assert "auto_cleanup" not in table_html + # Non-excluded fixtures must remain + assert "my_server" in table_html + + +# --------------------------------------------------------------------------- +# TYPE_CHECKING forward-reference regression test +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_type_checking_return_type_resolves_in_store( + tmp_path: pathlib.Path, +) -> None: + """TYPE_CHECKING return type is qualified via AST so Sphinx can cross-ref. + + Regression test for commit bcd9e2fe: fixtures with return types behind + ``if TYPE_CHECKING:`` rendered as unlinked text because the bare string + annotation couldn't be resolved to a fully-qualified name. + """ + fixture_source = textwrap.dedent( + """\ + from __future__ import annotations + import typing as t + import pytest + + class Widget: + \"\"\"A widget.\"\"\" + + if t.TYPE_CHECKING: + from fixture_mod import Widget as WidgetType + + @pytest.fixture + def my_widget() -> "WidgetType": + \"\"\"Return a widget.\"\"\" + return Widget() + """, + ) + + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture-index:: fixture_mod + + .. autofixture:: fixture_mod.my_widget + """, + ) + + result = _build_sphinx_app( + tmp_path, + fixture_source=fixture_source, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + + # The return type should be fully qualified via AST parsing, + # not left as bare "WidgetType". + store = result.app.env.domaindata.get("sphinx_autodoc_pytest_fixtures", {}) + meta = store["fixtures"]["fixture_mod.my_widget"] + assert meta.return_display == "fixture_mod.Widget", ( + f"Expected qualified name but got {meta.return_display!r}" + ) + assert meta.return_xref_target == "fixture_mod.Widget" + + +# --------------------------------------------------------------------------- +# Teardown summary rendering +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_teardown_summary_rendered_in_callout(tmp_path: pathlib.Path) -> None: + """teardown-summary option appends text to the yield-fixture callout note.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. py:fixture:: yield_server + :module: fixture_mod + :teardown: + :teardown-summary: Shuts down the server and cleans socket files. + """, + ) + 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 "Shuts down the server and cleans socket files" in html + + +# --------------------------------------------------------------------------- +# Deprecated replacement cross-reference rendering +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_deprecated_replacement_renders_hyperlink(tmp_path: pathlib.Path) -> None: + """Deprecated replacement :fixture: role renders as a hyperlink, not raw text.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. autofixture:: fixture_mod.my_server + + .. py:fixture:: old_server + :module: fixture_mod + :deprecated: 2.0 + :replacement: my_server + + An old server fixture. + """, + ) + 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") + # The replacement must render as a hyperlink, not literal :fixture: text + assert "Deprecated since version 2.0" in html + assert ":fixture:" not in html, ( + "Replacement fixture rendered as literal :fixture: text instead of a link" + ) + + +# --------------------------------------------------------------------------- +# AutofixturesDirective regression tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_autofixtures_directive_default_order(tmp_path: pathlib.Path) -> None: + """autofixtures:: without options renders all fixtures from the module.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. py:module:: fixture_mod + + .. autofixtures:: fixture_mod + """, + ) + 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") + # All public fixtures should appear + assert "my_server" in html + assert "my_client" in html + assert "TestServer" in html + + +@pytest.mark.integration +def test_autofixtures_directive_alpha_order(tmp_path: pathlib.Path) -> None: + """autofixtures:: with :order: alpha builds successfully.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. py:module:: fixture_mod + + .. autofixtures:: fixture_mod + :order: alpha + """, + ) + 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 "my_server" in html + assert "my_client" in html + + +@pytest.mark.integration +def test_autofixtures_directive_exclude(tmp_path: pathlib.Path) -> None: + """autofixtures:: with :exclude: omits named fixtures.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. py:module:: fixture_mod + + .. autofixtures:: fixture_mod + :exclude: my_client, auto_cleanup + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + store = result.app.env.domaindata.get("sphinx_autodoc_pytest_fixtures", {}) + fixtures = store.get("fixtures", {}) + # Excluded fixtures should NOT be in the store + assert "fixture_mod.my_client" not in fixtures + assert "fixture_mod.auto_cleanup" not in fixtures + # Non-excluded fixtures should be present + assert "fixture_mod.my_server" in fixtures + + +@pytest.mark.integration +def test_autofixtures_directive_import_error(tmp_path: pathlib.Path) -> None: + """autofixtures:: with a nonexistent module completes without crash.""" + index_rst = textwrap.dedent( + """\ + Test fixtures + ============= + + .. autofixtures:: nonexistent_module_xyz_12345 + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + # Build should complete, and warnings should mention the module + assert "nonexistent_module_xyz_12345" in result.warnings + + +# --------------------------------------------------------------------------- +# Search pair index entries +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_scope_pair_index_entry(tmp_path: pathlib.Path) -> None: + """Session-scoped fixtures get scope-qualified pair index entries.""" + result = _build_sphinx_app( + tmp_path, + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + # Check that the Sphinx domain has index entries for session-scoped fixtures. + # my_server is session-scoped in FIXTURE_MOD_SOURCE. + store = result.app.env.domaindata.get("sphinx_autodoc_pytest_fixtures", {}) + meta = store["fixtures"].get("fixture_mod.my_server") + assert meta is not None + assert meta.scope == "session" + # Verify the pair index entry structure in genindex.html: + # add_target_and_index emits ("pair", "session-scoped fixtures; my_server", ...) + genindex_html = (result.outdir / "genindex.html").read_text(encoding="utf-8") + assert "session-scoped fixtures" in genindex_html + assert "my_server" in genindex_html + # The pair entry links back to the fixture anchor in index.html + assert 'href="index.html#fixture_mod.my_server"' in genindex_html + + +# --------------------------------------------------------------------------- +# Parametrized values enumerated list rendering +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_parametrized_more_than_three_renders_enumerated_list( + tmp_path: pathlib.Path, +) -> None: + """Parametrized fixtures with >3 values render as an enumerated list.""" + extra_fixture = textwrap.dedent( + """\ + + @pytest.fixture(params=["bash", "zsh", "fish", "nushell"]) + def shell(request) -> str: + \"\"\"Fixture parametrized over shell interpreters.\"\"\" + return request.param + """, + ) + result = _build_sphinx_app( + tmp_path, + fixture_source=FIXTURE_MOD_SOURCE + extra_fixture, + index_rst=INDEX_RST + "\n.. autofixture:: fixture_mod.shell\n", + confoverrides={"pytest_fixture_lint_level": "none"}, + ) + html = (result.outdir / "index.html").read_text(encoding="utf-8") + assert "Parametrized" in html + # >3 items should render as an enumerated (ordered) list + assert " enumerated list for >3 parametrized values" + assert "'bash'" in html + assert "'nushell'" in html + + +# --------------------------------------------------------------------------- +# Manual py:fixture directive option tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_manual_fixture_deprecated_option(tmp_path: pathlib.Path) -> None: + """Manual py:fixture with :deprecated: renders callout and badge.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. py:fixture:: old_thing + :deprecated: 1.5 + + An old fixture. + """, + ) + 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 "Deprecated since version 1.5" in html + from sphinx_autodoc_pytest_fixtures import _CSS + + assert _CSS.DEPRECATED in html + + +@pytest.mark.integration +def test_manual_fixture_async_option(tmp_path: pathlib.Path) -> None: + """Manual py:fixture with :async: renders async fixture callout.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. py:fixture:: async_thing + :async: + + An async fixture. + """, + ) + 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 "async fixture" in html.lower() + + +# --------------------------------------------------------------------------- +# Multi-document "Used by" cross-doc links +# --------------------------------------------------------------------------- + +CROSS_DOC_FIXTURE_SOURCE = textwrap.dedent( + """\ + from __future__ import annotations + import typing as t + import pytest + + class Server: + \"\"\"A fake server.\"\"\" + + @pytest.fixture(scope="session") + def cross_server() -> Server: + \"\"\"A session-scoped server fixture.\"\"\" + return Server() + + @pytest.fixture + def cross_client(cross_server: Server) -> str: + \"\"\"A client that depends on cross_server.\"\"\" + return f"client@{cross_server}" + """, +) + +CROSS_DOC_API_RST = textwrap.dedent( + """\ + API + === + + .. py:module:: fixture_mod + + .. autofixture:: fixture_mod.cross_server + """, +) + +CROSS_DOC_USAGE_RST = textwrap.dedent( + """\ + Usage + ===== + + .. autofixture:: fixture_mod.cross_client + """, +) + +CROSS_DOC_INDEX_RST = textwrap.dedent( + """\ + Test + ==== + + .. toctree:: + + api + usage + """, +) + + +@pytest.mark.integration +def test_cross_doc_used_by_link(tmp_path: pathlib.Path) -> None: + """Used-by links work across documents. + + Fixture defined on api.rst, consumer on usage.rst — the "Used by" field + must render a cross-document anchor link. + """ + from sphinx.application import Sphinx + + srcdir = tmp_path / "src" + srcdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + doctreedir = tmp_path / ".doctrees" + doctreedir.mkdir() + + (srcdir / "fixture_mod.py").write_text(CROSS_DOC_FIXTURE_SOURCE, encoding="utf-8") + conf = CONF_PY_TEMPLATE.format(srcdir=str(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") + (srcdir / "usage.rst").write_text(CROSS_DOC_USAGE_RST, encoding="utf-8") + + status_buf = io.StringIO() + warning_buf = io.StringIO() + + _purge_fixture_module() + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="html", + confoverrides={"pytest_fixture_lint_level": "none"}, + status=status_buf, + warning=warning_buf, + freshenv=True, + ) + app.build() + + # cross_server is on api.html; its "Used by" should link to usage.html#cross_client + api_html = (outdir / "api.html").read_text(encoding="utf-8") + assert "Used by" in api_html + cross_client_href = 'href="usage.html#fixture_mod.cross_client"' + assert cross_client_href in api_html + + +# --------------------------------------------------------------------------- +# pytest_external_fixture_links config +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_external_fixture_links_renders_url(tmp_path: pathlib.Path) -> None: + """pytest_external_fixture_links maps fixture dep names to external URLs.""" + index_rst = textwrap.dedent( + """\ + Test + ==== + + .. py:module:: fixture_mod + + .. py:fixture:: my_widget + :depends: special_dep + """, + ) + result = _build_sphinx_app( + tmp_path, + index_rst=index_rst, + confoverrides={ + "pytest_fixture_lint_level": "none", + "pytest_external_fixture_links": { + "special_dep": "https://example.com/fixtures/special_dep", + }, + }, + ) + index_html = (result.outdir / "index.html").read_text(encoding="utf-8") + # The external URL should appear in the rendered anchor tag + assert "https://example.com/fixtures/special_dep" in index_html + assert "special_dep" in index_html + + +# --------------------------------------------------------------------------- +# Text builder smoke test +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_text_builder_does_not_crash(tmp_path: pathlib.Path) -> None: + """Extension does not crash when building with the text builder. + + badge nodes use nodes.abbreviation (portable), but the text builder path + was never tested. + """ + from sphinx.application import Sphinx + + srcdir = tmp_path / "src" + srcdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + doctreedir = tmp_path / "doctrees" + doctreedir.mkdir() + + (srcdir / "fixture_mod.py").write_text(FIXTURE_MOD_SOURCE, encoding="utf-8") + conf_py = CONF_PY_TEMPLATE.format(srcdir=str(srcdir)) + (srcdir / "conf.py").write_text(conf_py, encoding="utf-8") + (srcdir / "index.rst").write_text(INDEX_RST, encoding="utf-8") + + status_buf = io.StringIO() + warning_buf = io.StringIO() + + _purge_fixture_module() + app = Sphinx( + srcdir=str(srcdir), + confdir=str(srcdir), + outdir=str(outdir), + doctreedir=str(doctreedir), + buildername="text", + confoverrides={"pytest_fixture_lint_level": "none"}, + status=status_buf, + warning=warning_buf, + freshenv=True, + ) + # Should not raise — the text builder must handle badge nodes gracefully + app.build() + output_text = (outdir / "index.txt").read_text(encoding="utf-8") + # Basic sanity: fixture names appear in the text output + assert "my_server" in output_text diff --git a/uv.lock b/uv.lock index 99f64e3..17411ef 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ members = [ "gp-sphinx", "gp-sphinx-workspace", "sphinx-argparse-neo", + "sphinx-autodoc-pytest-fixtures", "sphinx-fonts", "sphinx-gptheme", ] @@ -462,6 +463,7 @@ dev = [ { name = "sphinx-argparse-neo" }, { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-pytest-fixtures" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "types-docutils" }, { name = "types-pygments" }, @@ -485,6 +487,7 @@ dev = [ { name = "ruff" }, { name = "sphinx-argparse-neo", editable = "packages/sphinx-argparse-neo" }, { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-pytest-fixtures", editable = "packages/sphinx-autodoc-pytest-fixtures" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "types-docutils" }, { name = "types-pygments" }, @@ -1241,6 +1244,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, ] +[[package]] +name = "sphinx-autodoc-pytest-fixtures" +version = "0.0.1a0" +source = { editable = "packages/sphinx-autodoc-pytest-fixtures" } +dependencies = [ + { name = "pytest" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest" }, + { name = "sphinx" }, +] + [[package]] name = "sphinx-autodoc-typehints" version = "3.0.1"