Skip to content

Commit e1dbff5

Browse files
committed
Added code and tests.
1 parent 2870d16 commit e1dbff5

File tree

5 files changed

+242
-1
lines changed

5 files changed

+242
-1
lines changed

.isort.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ known_third_party =
2222
pytest-randomly
2323
pytest-timeout
2424
requests
25+
sphinx
2526
known_first_party = sphinx_autofixture

doc-source/requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ default-values>=0.2.0
33
domdf-sphinx-theme>=0.1.0
44
extras-require>=0.2.0
55
seed-intersphinx-mapping>=0.1.1
6-
sphinx>=3.0.3
76
sphinx-copybutton>=0.2.12
87
sphinx-notfound-page>=0.5
98
sphinx-prompt>=1.1.0

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sphinx>=3.3.1

sphinx_autofixture/__init__.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,163 @@
2626
# OR OTHER DEALINGS IN THE SOFTWARE.
2727
#
2828

29+
# stdlib
30+
import ast
31+
import inspect
32+
from types import FunctionType
33+
from typing import Any, Dict, Optional, Tuple
34+
35+
# 3rd party
36+
from sphinx.application import Sphinx
37+
from sphinx.domains import ObjType
38+
from sphinx.domains.python import PyClasslike, PyXRefRole
39+
from sphinx.ext.autodoc import FunctionDocumenter, Options
40+
from sphinx.ext.autodoc.directive import DocumenterBridge
41+
from sphinx.locale import _
42+
2943
__author__: str = "Dominic Davis-Foster"
3044
__copyright__: str = "2020 Dominic Davis-Foster"
3145
__license__: str = "MIT License"
3246
__version__: str = "0.0.0"
3347
__email__: str = "dominic@davis-foster.co.uk"
48+
49+
__all__ = ["FixtureDecoratorFinder", "FixtureDocumenter", "is_fixture", "setup"]
50+
51+
52+
class FixtureDecoratorFinder(ast.NodeVisitor):
53+
"""
54+
:class:`ast.NodeVisitor` for finding pytest fixtures.
55+
"""
56+
57+
def __init__(self):
58+
59+
#: Is the function a fixture?
60+
self.is_fixture = False
61+
62+
#: If it is, the scope of the fixture.
63+
self.scope = "function"
64+
65+
def visit_FunctionDef(self, node: ast.FunctionDef) -> Any:
66+
if node.decorator_list:
67+
for deco in node.decorator_list:
68+
69+
if isinstance(deco, ast.Call):
70+
keywords: Dict[str, ast.Constant] = {k.arg: k.value for k in deco.keywords}
71+
72+
if "scope" in keywords:
73+
scope = keywords["scope"]
74+
if isinstance(scope, ast.Constant):
75+
self.scope = scope.value
76+
else: # pragma: no cover
77+
raise NotImplementedError(type(scope))
78+
79+
deco = deco.func
80+
else:
81+
self.scope = "function"
82+
83+
if isinstance(deco, ast.Name):
84+
if deco.id == "fixture":
85+
self.is_fixture = True
86+
return
87+
elif isinstance(deco, ast.Attribute):
88+
if deco.attr == "fixture":
89+
self.is_fixture = True
90+
return
91+
else: # pragma: no cover
92+
raise NotImplementedError(str(type(deco)))
93+
94+
95+
def is_fixture(function: FunctionType) -> Tuple[bool, Optional[str]]:
96+
"""
97+
Returns whether the given function is a fixture, and the fixture's scope if it is.
98+
99+
:param function:
100+
"""
101+
102+
visitor = FixtureDecoratorFinder()
103+
104+
try:
105+
visitor.visit(ast.parse(inspect.getsource(function)))
106+
except IndentationError:
107+
# Triggered when trying to parse a method
108+
return False, None
109+
110+
if not visitor.is_fixture:
111+
return False, None
112+
113+
return True, visitor.scope
114+
115+
116+
class FixtureDocumenter(FunctionDocumenter):
117+
"""
118+
Sphinx autodoc :class:`~sphinx.ext.autodoc.Documenter` for documenting pytest fixtures.
119+
"""
120+
121+
objtype = "fixture"
122+
directivetype = "fixture"
123+
priority = 20
124+
object: FunctionType # noqa: A003
125+
126+
def __init__(self, directive: DocumenterBridge, name: str, indent: str = '') -> None:
127+
super().__init__(directive, name, indent)
128+
self.options = Options(self.options.copy())
129+
130+
@classmethod
131+
def can_document_member(
132+
cls,
133+
member: Any,
134+
membername: str,
135+
isattr: bool,
136+
parent: Any,
137+
) -> bool:
138+
"""
139+
Called to see if a member can be documented by this documenter.
140+
141+
:param member: The member being checked.
142+
:param membername: The name of the member.
143+
:param isattr:
144+
:param parent: The parent of the member.
145+
"""
146+
147+
if isinstance(member, FunctionType):
148+
return is_fixture(member)[0]
149+
else:
150+
return False
151+
152+
def add_directive_header(self, sig: str = '') -> None:
153+
"""
154+
Add the directive's header.
155+
156+
:param sig: Unused -- fixtures have no useful signature.
157+
"""
158+
159+
# doc_lines = (self.object.__doc__ or '').splitlines()
160+
# docstring = StringList([dedent(doc_lines[0]) + dedent("\n".join(doc_lines))[1:]])
161+
# print(docstring)
162+
# input(">>>")
163+
164+
super().add_directive_header('')
165+
166+
self.add_line('', self.get_sourcename())
167+
self.add_line(
168+
f" **Scope:** |nbsp| |nbsp| |nbsp| |nbsp| {is_fixture(self.object)[1]}", self.get_sourcename()
169+
)
170+
171+
172+
def setup(app: Sphinx) -> Dict[str, Any]:
173+
"""
174+
Setup :mod:`sphinx_autofixture`.
175+
176+
:param app: The Sphinx app.
177+
"""
178+
179+
app.registry.domains["py"].object_types["fixture"] = ObjType(_("fixture"), "fixture", "function", "obj")
180+
app.add_directive_to_domain("py", "fixture", PyClasslike)
181+
app.add_role_to_domain("py", "fixture", PyXRefRole())
182+
183+
app.add_autodocumenter(FixtureDocumenter)
184+
185+
return {
186+
"version": __version__,
187+
"parallel_read_safe": True,
188+
}

tests/test_is_fixture.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import pytest
2+
from pytest import fixture
3+
4+
from sphinx_autofixture import is_fixture
5+
6+
7+
@fixture
8+
def name():
9+
"""
10+
:return:
11+
"""
12+
13+
14+
@fixture()
15+
def call():
16+
"""
17+
18+
:return:
19+
"""
20+
21+
22+
@fixture(scope="module")
23+
def call_scoped():
24+
"""
25+
26+
:return:
27+
"""
28+
29+
30+
@pytest.fixture
31+
def pytest_attribute():
32+
"""
33+
:return:
34+
"""
35+
36+
37+
@pytest.fixture()
38+
def pytest_call():
39+
"""
40+
41+
:return:
42+
"""
43+
44+
45+
@pytest.fixture(scope="module")
46+
def pytest_call_scoped():
47+
"""
48+
:return:
49+
"""
50+
51+
52+
@pytest.mark.parametrize("func, scope", [
53+
pytest.param(name, "function", id="name"),
54+
pytest.param(call, "function", id="call"),
55+
pytest.param(call_scoped, "module", id="call_scoped"),
56+
pytest.param(pytest_attribute, "function", id="pytest_attribute"),
57+
pytest.param(pytest_call, "function", id="pytest_call"),
58+
pytest.param(pytest_call_scoped, "module", id="pytest_call_scoped"),
59+
])
60+
def test_is_fixture(func, scope):
61+
assert is_fixture(func) == (True, scope)
62+
63+
64+
def function(): pass
65+
66+
67+
class Class:
68+
69+
def method(self): pass
70+
71+
72+
def deco(func): return func
73+
74+
@deco
75+
def decorated(): pass
76+
77+
78+
@pytest.mark.parametrize("func", [
79+
pytest.param(function, id="function"),
80+
pytest.param(decorated, id="decorated"),
81+
pytest.param(Class.method, id="Class.method"),
82+
pytest.param(Class().method, id="Class().method"),
83+
])
84+
def test_isnt_fixture(func):
85+
assert is_fixture(func) == (False, None)

0 commit comments

Comments
 (0)