Skip to content

Commit 49772c1

Browse files
authored
Add sphinx-autodoc-pytest-fixtures package (#6)
New workspace package that documents pytest fixtures as first-class Sphinx domain objects. Adds `py:fixture` directive/role, `autofixture::` autodocumenter, `autofixtures::` for bulk module discovery, and `autofixture-index::` for summary tables with badge columns. Fixtures render with scope badges (session/module/class), kind badges (factory/override_hook), autouse indicators, classified dependency lists (project fixtures as internal xrefs, pytest builtins as external links), auto-generated usage snippets, and reverse-dep "Used by" sections. - **Data model**: frozen dataclasses (`FixtureMeta`, `FixtureDep`) for pickle-safe Sphinx build environment storage - **Build safety**: parallel_read_safe + parallel_write_safe with store rebuild on env-merge-info and env-purge-doc - **Accessibility**: WCAG AA contrast badges via `<abbr>` with tabindex for keyboard/touch tooltip access - **Compatibility**: handles pytest 9+ `FixtureFunctionDefinition` and older marker style; AST-based TYPE_CHECKING forward-ref resolution - **Validation**: codes SPF001–SPF006 with configurable lint level - **Badge style**: dotted underline on `abbr.spf-badge` to signal tooltip availability, matching browser `abbr[title]` convention
2 parents 75dfae5 + 383b35d commit 49772c1

22 files changed

Lines changed: 7022 additions & 1 deletion

packages/sphinx-autodoc-pytest-fixtures/README.md

Whitespace-only changes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[project]
2+
name = "sphinx-autodoc-pytest-fixtures"
3+
version = "0.0.1a0"
4+
description = "Sphinx extension for documenting pytest fixtures as first-class objects"
5+
requires-python = ">=3.10,<4.0"
6+
authors = [
7+
{name = "Tony Narlock", email = "tony@git-pull.com"}
8+
]
9+
license = { text = "MIT" }
10+
classifiers = [
11+
"Development Status :: 3 - Alpha",
12+
"License :: OSI Approved :: MIT License",
13+
"Framework :: Sphinx",
14+
"Framework :: Sphinx :: Extension",
15+
"Framework :: Pytest",
16+
"Intended Audience :: Developers",
17+
"Programming Language :: Python :: 3",
18+
"Programming Language :: Python :: 3.10",
19+
"Programming Language :: Python :: 3.11",
20+
"Programming Language :: Python :: 3.12",
21+
"Programming Language :: Python :: 3.13",
22+
"Programming Language :: Python :: 3.14",
23+
"Topic :: Documentation",
24+
"Topic :: Documentation :: Sphinx",
25+
"Topic :: Software Development :: Testing",
26+
"Typing :: Typed",
27+
]
28+
readme = "README.md"
29+
keywords = ["sphinx", "pytest", "fixtures", "documentation", "autodoc"]
30+
dependencies = [
31+
"sphinx",
32+
"pytest",
33+
]
34+
35+
[project.urls]
36+
Repository = "https://github.com/git-pull/gp-sphinx"
37+
38+
[build-system]
39+
requires = ["hatchling"]
40+
build-backend = "hatchling.build"
41+
42+
[tool.hatch.build.targets.wheel]
43+
packages = ["src/sphinx_autodoc_pytest_fixtures"]
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Sphinx extension for documenting pytest fixtures as first-class objects.
2+
3+
Registers ``py:fixture`` as a domain directive and ``autofixture::`` as an
4+
autodoc documenter. Fixtures are rendered with their scope, user-visible
5+
dependencies, and an auto-generated usage snippet rather than as plain
6+
callable signatures.
7+
8+
.. note::
9+
10+
This extension self-registers its CSS via ``add_css_file()``. The rules
11+
live in ``_static/css/sphinx_autodoc_pytest_fixtures.css`` inside this package.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
import typing as t
18+
19+
from docutils import nodes
20+
from sphinx.domains import ObjType
21+
from sphinx.domains.python import PythonDomain, PyXRefRole
22+
23+
# ---------------------------------------------------------------------------
24+
# Re-exports for backward compatibility (tests access these via the package)
25+
# ---------------------------------------------------------------------------
26+
from sphinx_autodoc_pytest_fixtures._badges import (
27+
_BADGE_TOOLTIPS,
28+
_build_badge_group_node,
29+
)
30+
from sphinx_autodoc_pytest_fixtures._constants import (
31+
_CONFIG_BUILTIN_LINKS,
32+
_CONFIG_EXTERNAL_LINKS,
33+
_CONFIG_HIDDEN_DEPS,
34+
_CONFIG_LINT_LEVEL,
35+
_EXTENSION_KEY,
36+
_EXTENSION_VERSION,
37+
_STORE_VERSION,
38+
PYTEST_BUILTIN_LINKS,
39+
PYTEST_HIDDEN,
40+
SetupDict,
41+
)
42+
from sphinx_autodoc_pytest_fixtures._css import _CSS
43+
from sphinx_autodoc_pytest_fixtures._detection import (
44+
_classify_deps,
45+
_get_fixture_fn,
46+
_get_fixture_marker,
47+
_get_return_annotation,
48+
_get_user_deps,
49+
_is_factory,
50+
_is_pytest_fixture,
51+
_iter_injectable_params,
52+
)
53+
from sphinx_autodoc_pytest_fixtures._directives import (
54+
AutofixtureIndexDirective,
55+
AutofixturesDirective,
56+
PyFixtureDirective,
57+
)
58+
from sphinx_autodoc_pytest_fixtures._documenter import FixtureDocumenter
59+
from sphinx_autodoc_pytest_fixtures._metadata import (
60+
_build_usage_snippet,
61+
_has_authored_example,
62+
_register_fixture_meta,
63+
)
64+
from sphinx_autodoc_pytest_fixtures._models import (
65+
FixtureDep,
66+
FixtureMeta,
67+
autofixture_index_node,
68+
)
69+
from sphinx_autodoc_pytest_fixtures._store import (
70+
_finalize_store,
71+
_get_spf_store,
72+
_on_env_merge_info,
73+
_on_env_purge_doc,
74+
_on_env_updated,
75+
)
76+
from sphinx_autodoc_pytest_fixtures._transforms import (
77+
_depart_abbreviation_html,
78+
_on_doctree_resolved,
79+
_on_missing_reference,
80+
_visit_abbreviation_html,
81+
)
82+
83+
if t.TYPE_CHECKING:
84+
from sphinx.application import Sphinx
85+
86+
logging.getLogger(__name__).addHandler(logging.NullHandler())
87+
88+
89+
def setup(app: Sphinx) -> SetupDict:
90+
"""Register the ``sphinx_autodoc_pytest_fixtures`` extension.
91+
92+
Parameters
93+
----------
94+
app : Sphinx
95+
The Sphinx application instance.
96+
97+
Returns
98+
-------
99+
SetupDict
100+
Extension metadata dict.
101+
"""
102+
app.setup_extension("sphinx.ext.autodoc")
103+
104+
# Register extension CSS so projects adopting this extension get styled
105+
# output without manually copying spf-* rules into their custom.css.
106+
import pathlib
107+
108+
_static_dir = str(pathlib.Path(__file__).parent / "_static")
109+
110+
def _add_static_path(app: Sphinx) -> None:
111+
if _static_dir not in app.config.html_static_path:
112+
app.config.html_static_path.append(_static_dir)
113+
114+
app.connect("builder-inited", _add_static_path)
115+
app.add_css_file("css/sphinx_autodoc_pytest_fixtures.css")
116+
117+
# Override the built-in abbreviation visitor to emit tabindex when set.
118+
# Sphinx's default visit_abbreviation only passes explanation → title,
119+
# silently dropping all other attributes. This override is a strict
120+
# superset — non-badge abbreviation nodes produce identical output.
121+
app.add_node(
122+
nodes.abbreviation,
123+
override=True,
124+
html=(_visit_abbreviation_html, _depart_abbreviation_html),
125+
)
126+
127+
# --- New config values (v1.1) ---
128+
app.add_config_value(
129+
_CONFIG_HIDDEN_DEPS,
130+
default=PYTEST_HIDDEN,
131+
rebuild="env",
132+
types=[frozenset],
133+
)
134+
app.add_config_value(
135+
_CONFIG_BUILTIN_LINKS,
136+
default=PYTEST_BUILTIN_LINKS,
137+
rebuild="env",
138+
types=[dict],
139+
)
140+
app.add_config_value(
141+
_CONFIG_EXTERNAL_LINKS,
142+
default={},
143+
rebuild="env",
144+
types=[dict],
145+
)
146+
app.add_config_value(
147+
_CONFIG_LINT_LEVEL,
148+
default="warning",
149+
rebuild="env",
150+
types=[str],
151+
)
152+
153+
# Register std:fixture so :external+pytest:std:fixture: intersphinx
154+
# references resolve. Pytest registers this in their own conf.py;
155+
# we mirror it so the role is known locally.
156+
app.add_crossref_type("fixture", "fixture")
157+
158+
# Guard against re-registration when setup() is called multiple times.
159+
if "fixture" not in PythonDomain.object_types:
160+
PythonDomain.object_types["fixture"] = ObjType(
161+
"fixture",
162+
"fixture",
163+
"func",
164+
"obj",
165+
)
166+
app.add_directive_to_domain("py", "fixture", PyFixtureDirective)
167+
app.add_role_to_domain("py", "fixture", PyXRefRole())
168+
169+
app.add_autodocumenter(FixtureDocumenter)
170+
app.add_directive("autofixtures", AutofixturesDirective)
171+
app.add_node(autofixture_index_node)
172+
app.add_directive("autofixture-index", AutofixtureIndexDirective)
173+
174+
app.connect("missing-reference", _on_missing_reference)
175+
app.connect("doctree-resolved", _on_doctree_resolved)
176+
app.connect("env-purge-doc", _on_env_purge_doc)
177+
app.connect("env-merge-info", _on_env_merge_info)
178+
app.connect("env-updated", _on_env_updated)
179+
180+
return {
181+
"version": _EXTENSION_VERSION,
182+
"env_version": _STORE_VERSION,
183+
"parallel_read_safe": True,
184+
"parallel_write_safe": True,
185+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Badge group rendering helpers for sphinx_autodoc_pytest_fixtures."""
2+
3+
from __future__ import annotations
4+
5+
from docutils import nodes
6+
7+
from sphinx_autodoc_pytest_fixtures._constants import _SUPPRESSED_SCOPES
8+
from sphinx_autodoc_pytest_fixtures._css import _CSS
9+
10+
_BADGE_TOOLTIPS: dict[str, str] = {
11+
"session": "Scope: session \u2014 created once per test session",
12+
"module": "Scope: module \u2014 created once per test module",
13+
"class": "Scope: class \u2014 created once per test class",
14+
"factory": "Factory \u2014 returns a callable that creates instances",
15+
"override_hook": "Override hook \u2014 customize in conftest.py",
16+
"fixture": "pytest fixture \u2014 injected by name into test functions",
17+
"autouse": "Runs automatically for every test (autouse=True)",
18+
"deprecated": "Deprecated \u2014 see docs for replacement",
19+
}
20+
21+
22+
def _build_badge_group_node(
23+
scope: str,
24+
kind: str,
25+
autouse: bool,
26+
*,
27+
deprecated: bool = False,
28+
show_fixture_badge: bool = True,
29+
) -> nodes.inline:
30+
"""Return a badge group as portable ``nodes.abbreviation`` nodes.
31+
32+
Each badge renders as ``<abbr title="...">`` in HTML, providing hover
33+
tooltips. Non-HTML builders fall back to plain text.
34+
35+
Badge slots (left-to-right in visual order):
36+
37+
* Slot 0 (deprecated): shown when fixture is deprecated
38+
* Slot 1 (scope): shown when ``scope != "function"``
39+
* Slot 2 (kind): shown for ``"factory"`` / ``"override_hook"``; or
40+
state badge (``"autouse"``) when ``autouse=True``
41+
* Slot 3 (FIXTURE): shown when ``show_fixture_badge=True`` (default)
42+
43+
Parameters
44+
----------
45+
scope : str
46+
Fixture scope string.
47+
kind : str
48+
Fixture kind string.
49+
autouse : bool
50+
When True, renders AUTO state badge instead of a kind badge.
51+
deprecated : bool
52+
When True, renders a deprecated badge at slot 0 (leftmost).
53+
show_fixture_badge : bool
54+
When False, suppresses the FIXTURE badge at slot 3. Use in contexts
55+
where the fixture type is already implied (e.g. an index table).
56+
57+
Returns
58+
-------
59+
nodes.inline
60+
Badge group container with abbreviation badge children.
61+
"""
62+
group = nodes.inline(classes=[_CSS.BADGE_GROUP])
63+
badges: list[nodes.abbreviation] = []
64+
65+
# Slot 0 — deprecated badge (leftmost when present)
66+
if deprecated:
67+
badges.append(
68+
nodes.abbreviation(
69+
"deprecated",
70+
"deprecated",
71+
explanation=_BADGE_TOOLTIPS["deprecated"],
72+
classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.DEPRECATED],
73+
)
74+
)
75+
76+
# Slot 1 — scope badge (only non-function scope)
77+
if scope and scope not in _SUPPRESSED_SCOPES:
78+
badges.append(
79+
nodes.abbreviation(
80+
scope,
81+
scope,
82+
explanation=_BADGE_TOOLTIPS.get(scope, f"Scope: {scope}"),
83+
classes=[_CSS.BADGE, _CSS.BADGE_SCOPE, _CSS.scope(scope)],
84+
)
85+
)
86+
87+
# Slot 2 — kind or autouse badge
88+
if autouse:
89+
badges.append(
90+
nodes.abbreviation(
91+
"auto",
92+
"auto",
93+
explanation=_BADGE_TOOLTIPS["autouse"],
94+
classes=[_CSS.BADGE, _CSS.BADGE_STATE, _CSS.AUTOUSE],
95+
)
96+
)
97+
elif kind == "factory":
98+
badges.append(
99+
nodes.abbreviation(
100+
"factory",
101+
"factory",
102+
explanation=_BADGE_TOOLTIPS["factory"],
103+
classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.FACTORY],
104+
)
105+
)
106+
elif kind == "override_hook":
107+
badges.append(
108+
nodes.abbreviation(
109+
"override",
110+
"override",
111+
explanation=_BADGE_TOOLTIPS["override_hook"],
112+
classes=[_CSS.BADGE, _CSS.BADGE_KIND, _CSS.OVERRIDE],
113+
)
114+
)
115+
116+
# Slot 3 — fixture badge (rightmost, suppressed in index table context)
117+
if show_fixture_badge:
118+
badges.append(
119+
nodes.abbreviation(
120+
"fixture",
121+
"fixture",
122+
explanation=_BADGE_TOOLTIPS["fixture"],
123+
classes=[_CSS.BADGE, _CSS.BADGE_FIXTURE],
124+
)
125+
)
126+
127+
# Make badges focusable for touch/keyboard tooltip accessibility.
128+
# Sphinx's built-in visit_abbreviation does NOT emit tabindex — our
129+
# custom visitor override (_visit_abbreviation_html) handles it.
130+
for badge in badges:
131+
badge["tabindex"] = "0"
132+
133+
# Interleave with text separators for non-HTML builders (CSS gap
134+
# handles spacing in HTML; text/LaTeX/man builders need explicit spaces).
135+
for i, badge in enumerate(badges):
136+
group += badge
137+
if i < len(badges) - 1:
138+
group += nodes.Text(" ")
139+
140+
return group

0 commit comments

Comments
 (0)