Skip to content

Commit 82a5c4b

Browse files
committed
feat(_ext[sphinx_pytest_fixtures]): add Sphinx extension for pytest fixture documentation
why: pytest fixtures lack first-class Sphinx support — they render as plain functions with full call signatures, hiding scope, dependencies, and usage patterns. This extension treats fixtures as domain objects with badge-based metadata, auto-generated usage snippets, and cross-referenced dependency graphs. what: - __init__.py: extension setup, domain registration (py:fixture directive + :fixture: xref role), CSS self-registration - _badges.py: scope/kind/autouse/deprecated badge nodes with WCAG AA contrast in light and dark mode - _constants.py: field labels, config keys, table spec, regex, extension version, and store version constants - _css.py: CSS class name constants for badge/card/index styling - _detection.py: fixture introspection — marker extraction, scope detection, dependency classification, return type annotation, factory/async detection, TYPE_CHECKING forward-ref resolution - _directives.py: PyFixtureDirective (py:fixture), AutofixturesDirective (bulk module discovery), AutofixtureIndexDirective (index table) - _documenter.py: autodoc-style FixtureDocumenter for autofixture:: - _index.py: index table builder with linked return types via intersphinx, Flags column with scope/kind/autouse badges - _metadata.py: FixtureMeta extraction — docstring summary, teardown summary from Teardown docstring section, usage snippet generation, forward-ref qualification - _models.py: FixtureMeta and FixtureDep dataclasses, TypedDict for store entries, autofixture_index_node placeholder - _store.py: environment-safe fixture store with merge-info collision detection (SPF009), finalization, and builtin URL resolution - _transforms.py: doctree transforms — badge injection, field list augmentation, dependency rendering, callout insertion - _validation.py: stable warning codes SPF001–SPF006 with configurable lint_level (warn or error) - _static/css/sphinx_pytest_fixtures.css: 387 lines of responsive CSS for badge pills, fixture cards, index table, mobile stacking, dark-mode tokens, and focus-accessible tooltips
1 parent 993ee06 commit 82a5c4b

14 files changed

Lines changed: 3630 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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_pytest_fixtures.css`` inside this package.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import typing as t
17+
18+
from docutils import nodes
19+
from sphinx.domains import ObjType
20+
from sphinx.domains.python import PythonDomain, PyXRefRole
21+
22+
# ---------------------------------------------------------------------------
23+
# Re-exports for backward compatibility (tests access these via the package)
24+
# ---------------------------------------------------------------------------
25+
from sphinx_pytest_fixtures._badges import (
26+
_BADGE_TOOLTIPS,
27+
_build_badge_group_node,
28+
)
29+
from sphinx_pytest_fixtures._constants import (
30+
_CONFIG_BUILTIN_LINKS,
31+
_CONFIG_EXTERNAL_LINKS,
32+
_CONFIG_HIDDEN_DEPS,
33+
_CONFIG_LINT_LEVEL,
34+
_EXTENSION_KEY,
35+
_EXTENSION_VERSION,
36+
_STORE_VERSION,
37+
PYTEST_BUILTIN_LINKS,
38+
PYTEST_HIDDEN,
39+
SetupDict,
40+
)
41+
from sphinx_pytest_fixtures._css import _CSS
42+
from sphinx_pytest_fixtures._detection import (
43+
_classify_deps,
44+
_get_fixture_fn,
45+
_get_fixture_marker,
46+
_get_return_annotation,
47+
_get_user_deps,
48+
_is_factory,
49+
_is_pytest_fixture,
50+
_iter_injectable_params,
51+
)
52+
from sphinx_pytest_fixtures._directives import (
53+
AutofixtureIndexDirective,
54+
AutofixturesDirective,
55+
PyFixtureDirective,
56+
)
57+
from sphinx_pytest_fixtures._documenter import FixtureDocumenter
58+
from sphinx_pytest_fixtures._metadata import (
59+
_build_usage_snippet,
60+
_has_authored_example,
61+
_register_fixture_meta,
62+
)
63+
from sphinx_pytest_fixtures._models import (
64+
FixtureDep,
65+
FixtureMeta,
66+
autofixture_index_node,
67+
)
68+
from sphinx_pytest_fixtures._store import (
69+
_finalize_store,
70+
_get_spf_store,
71+
_on_env_merge_info,
72+
_on_env_purge_doc,
73+
_on_env_updated,
74+
)
75+
from sphinx_pytest_fixtures._transforms import (
76+
_depart_abbreviation_html,
77+
_on_doctree_resolved,
78+
_on_missing_reference,
79+
_visit_abbreviation_html,
80+
)
81+
82+
if t.TYPE_CHECKING:
83+
from sphinx.application import Sphinx
84+
85+
86+
def setup(app: Sphinx) -> SetupDict:
87+
"""Register the ``sphinx_pytest_fixtures`` extension.
88+
89+
Parameters
90+
----------
91+
app : Sphinx
92+
The Sphinx application instance.
93+
94+
Returns
95+
-------
96+
SetupDict
97+
Extension metadata dict.
98+
"""
99+
app.setup_extension("sphinx.ext.autodoc")
100+
101+
# Register extension CSS so projects adopting this extension get styled
102+
# output without manually copying spf-* rules into their custom.css.
103+
import pathlib
104+
105+
_static_dir = str(pathlib.Path(__file__).parent / "_static")
106+
107+
def _add_static_path(app: Sphinx) -> None:
108+
if _static_dir not in app.config.html_static_path:
109+
app.config.html_static_path.append(_static_dir)
110+
111+
app.connect("builder-inited", _add_static_path)
112+
app.add_css_file("css/sphinx_pytest_fixtures.css")
113+
114+
# Override the built-in abbreviation visitor to emit tabindex when set.
115+
# Sphinx's default visit_abbreviation only passes explanation → title,
116+
# silently dropping all other attributes. This override is a strict
117+
# superset — non-badge abbreviation nodes produce identical output.
118+
app.add_node(
119+
nodes.abbreviation,
120+
override=True,
121+
html=(_visit_abbreviation_html, _depart_abbreviation_html),
122+
)
123+
124+
# --- New config values (v1.1) ---
125+
app.add_config_value(
126+
_CONFIG_HIDDEN_DEPS,
127+
default=PYTEST_HIDDEN,
128+
rebuild="env",
129+
types=[frozenset],
130+
)
131+
app.add_config_value(
132+
_CONFIG_BUILTIN_LINKS,
133+
default=PYTEST_BUILTIN_LINKS,
134+
rebuild="env",
135+
types=[dict],
136+
)
137+
app.add_config_value(
138+
_CONFIG_EXTERNAL_LINKS,
139+
default={},
140+
rebuild="env",
141+
types=[dict],
142+
)
143+
app.add_config_value(
144+
_CONFIG_LINT_LEVEL,
145+
default="warning",
146+
rebuild="env",
147+
types=[str],
148+
)
149+
150+
# Register std:fixture so :external+pytest:std:fixture: intersphinx
151+
# references resolve. Pytest registers this in their own conf.py;
152+
# we mirror it so the role is known locally.
153+
app.add_crossref_type("fixture", "fixture")
154+
155+
# Guard against re-registration when setup() is called multiple times.
156+
if "fixture" not in PythonDomain.object_types:
157+
PythonDomain.object_types["fixture"] = ObjType(
158+
"fixture",
159+
"fixture",
160+
"func",
161+
"obj",
162+
)
163+
app.add_directive_to_domain("py", "fixture", PyFixtureDirective)
164+
app.add_role_to_domain("py", "fixture", PyXRefRole())
165+
166+
app.add_autodocumenter(FixtureDocumenter)
167+
app.add_directive("autofixtures", AutofixturesDirective)
168+
app.add_node(autofixture_index_node)
169+
app.add_directive("autofixture-index", AutofixtureIndexDirective)
170+
171+
app.connect("missing-reference", _on_missing_reference)
172+
app.connect("doctree-resolved", _on_doctree_resolved)
173+
app.connect("env-purge-doc", _on_env_purge_doc)
174+
app.connect("env-merge-info", _on_env_merge_info)
175+
app.connect("env-updated", _on_env_updated)
176+
177+
return {
178+
"version": _EXTENSION_VERSION,
179+
"env_version": _STORE_VERSION,
180+
"parallel_read_safe": True,
181+
"parallel_write_safe": True,
182+
}
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_pytest_fixtures."""
2+
3+
from __future__ import annotations
4+
5+
from docutils import nodes
6+
7+
from sphinx_pytest_fixtures._constants import _SUPPRESSED_SCOPES
8+
from sphinx_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)