-
Notifications
You must be signed in to change notification settings - Fork 18
Expand file tree
/
Copy pathplugin.py
More file actions
404 lines (335 loc) · 14.9 KB
/
Copy pathplugin.py
File metadata and controls
404 lines (335 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
"""The pytest-describe plugin"""
from __future__ import annotations
import inspect
import sys
import types
from collections.abc import Callable, Iterable, Iterator
from typing import Any, NoReturn
import pytest
try:
from _pytest.fixtures import FixtureFunctionDefinition
except ImportError: # pragma: no cover (pytest < 8.4)
def is_function_or_fixture(obj: object) -> bool:
return isinstance(obj, types.FunctionType)
def is_fixture_function_definition(obj: object) -> bool:
return hasattr(obj, "_pytestfixturefunction")
else:
def is_function_or_fixture(obj: object) -> bool:
return isinstance(obj, types.FunctionType | FixtureFunctionDefinition)
def is_fixture_function_definition(obj: object) -> bool:
return isinstance(obj, FixtureFunctionDefinition)
class DescribeArgumentError(Exception):
"""Error raised when a describe argument is used outside of tests."""
class DescribeArgument:
"""Placeholder for a fixture passed as argument to a describe block.
Describe blocks run at collection time, when fixtures are not yet
available. The real fixture value is injected into the closure cells
before each test runs. Using the placeholder itself raises an error.
"""
__slots__ = ("name",)
def __init__(self, name: str) -> None:
self.name = name
def __repr__(self) -> str:
return (
f"<describe argument {self.name!r}"
" (the fixture is only available inside tests)>"
)
def _used(self, *args: Any, **kwargs: Any) -> NoReturn:
raise DescribeArgumentError(
f"The argument {self.name!r} is a placeholder for a fixture"
" that is only available inside the tests of the describe"
" block, it cannot be used in the describe block itself."
)
__call__ = __getattr__ = __getitem__ = __setitem__ = __delitem__ = _used
__iter__ = __contains__ = __len__ = __bool__ = _used
__eq__ = __ne__ = __lt__ = __le__ = __gt__ = __ge__ = _used
__add__ = __sub__ = __mul__ = __truediv__ = _used
__hash__ = object.__hash__
def get_describe_args(func: Callable[..., Any]) -> tuple[str, ...]:
"""Get the fixture names a describe block declares as parameters.
Mirroring how pytest determines fixture names for test functions,
only mandatory (non-defaulted) named parameters are considered.
"""
return tuple(
name
for name, param in inspect.signature(func).parameters.items()
if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY)
and param.default is param.empty
)
def find_argument_cells(namespace: dict[str, Any]) -> dict[str, list[types.CellType]]:
"""Find closure cells holding describe arguments in collected functions.
Cells are shared between all nested functions referencing the same
outer variable, but the same argument name can appear in multiple
cells, e.g. when a describe block and an imported shared behavior
both declare an argument with the same name.
"""
cells: dict[str, list[types.CellType]] = {}
for obj in namespace.values():
# Unwrap fixture function definitions (pytest >= 8.4)
func = inspect.unwrap(obj) if is_fixture_function_definition(obj) else obj
if not isinstance(func, types.FunctionType):
continue
for cell in func.__closure__ or ():
try:
contents = cell.cell_contents
except ValueError: # pragma: no cover (empty cell)
continue
if isinstance(contents, DescribeArgument):
name_cells = cells.setdefault(contents.name, [])
# check identity, equality would compare cell contents
if not any(c is cell for c in name_cells):
name_cells.append(cell)
return cells
def trace_function(
func: Callable[..., Any], *args: Any, **kwargs: Any
) -> dict[str, Any]:
"""Call a function and return its locals."""
f_locals: dict[str, Any] = {}
def _trace_func(
frame: types.FrameType, event: str, arg: Any
) -> None: # pragma: no cover
# Activate local trace for first call only
back = frame.f_back
if (
back is not None
and back.f_locals.get("_trace_func") is _trace_func
and event == "return"
):
f_locals.update(frame.f_locals)
sys.setprofile(_trace_func)
try:
func(*args, **kwargs)
finally:
sys.setprofile(None)
return f_locals
def make_module_from_function(
func: types.FunctionType, args: tuple[str, ...] = ()
) -> types.ModuleType:
"""Evaluate the local scope of a function as if it was a module."""
module = types.ModuleType(func.__name__)
# Import shared behaviors into the generated module. We do this before
# importing the direct children, so that fixtures in the block that's
# importing the behavior take precedence.
for shared_func in getattr(func, "_behaves_like", ()):
module.__dict__.update(evaluate_shared_behavior(shared_func))
# Import children, passing placeholders for declared fixture arguments.
# Placeholders are removed from the namespace; note that this includes
# arguments of outer describe blocks appearing here as free variables.
funclocals = {
name: obj
for name, obj in trace_function(
func, **{name: DescribeArgument(name) for name in args}
).items()
if not isinstance(obj, DescribeArgument)
}
module.__dict__.update(funclocals)
return module
def evaluate_shared_behavior(func: types.FunctionType) -> dict[str, Any]:
"""Evaluate the local scope of a function."""
try:
shared_functions: dict[str, Any] = func._shared_functions # type: ignore[attr-defined]
except AttributeError:
shared_functions = {}
funclocals = trace_function(
func,
**{name: DescribeArgument(name) for name in get_describe_args(func)},
)
for name, obj in funclocals.items():
# Only functions and fixtures are relevant here
if not is_function_or_fixture(obj):
continue
# Mangle names of imported functions, except fixtures
# because we want fixtures to be overridden in the block
# that's importing the behavior.
if not is_fixture_function_definition(obj):
name = obj._mangled_name = f"{func.__name__}::{name}"
shared_functions[name] = obj
func._shared_functions = shared_functions # type: ignore[attr-defined]
return shared_functions
class DescribeBlock(pytest.Module):
"""Module-like object representing the scope of a describe block"""
# Note: mypy applies the descriptor protocol to class-level attributes
# of type FunctionType, so we need to ignore some errors when using it.
funcobj: types.FunctionType
describe_args: tuple[str, ...]
describe_cells: dict[str, list[types.CellType]]
@classmethod
def from_parent( # type: ignore[override]
cls, parent: pytest.Collector, obj: types.FunctionType
) -> DescribeBlock:
"""Construct a new node for the describe block"""
name = getattr(obj, "_mangled_name", obj.__name__)
if parent.config.getini("describe_docstrings"):
doc = inspect.getdoc(obj)
if doc:
name = doc.splitlines()[0].strip()
nodeid = parent.nodeid + "::" + name
self: DescribeBlock = super().from_parent(
parent=parent, path=parent.path, nodeid=nodeid
)
self.name = name
self.funcobj = obj # type: ignore[assignment]
self.describe_args = get_describe_args(obj)
self.describe_cells = {}
return self
def collect(self) -> Iterable[pytest.Item | pytest.Collector]:
"""Get list of children"""
self.session._fixturemanager.parsefactories(self)
return super().collect()
def _getobj(self) -> types.ModuleType:
"""Get the underlying Python object"""
return self._importtestmodule()
def _importtestmodule(self) -> types.ModuleType:
"""Import a describe block as if it was a module"""
module = make_module_from_function(
self.funcobj, # type: ignore[arg-type]
self.describe_args,
)
# Arguments of imported shared behaviors are treated like
# arguments of the describe block that imports the behavior.
for shared_func in getattr(self.funcobj, "_behaves_like", ()):
for name in get_describe_args(shared_func):
if name not in self.describe_args:
self.describe_args += (name,)
self.describe_cells = find_argument_cells(module.__dict__)
if self.describe_args:
# Provide the autouse fixture that injects the fixture values
# into the closure cells before each test in this block.
module.__dict__[INJECTOR_FIXTURE_NAME] = make_injector_fixture()
self.own_markers = getattr(self.funcobj, "pytestmark", [])
return module
def funcnamefilter(self, name: str) -> bool:
"""Treat all nested functions as tests
We do not require the 'test_' prefix for the specs.
"""
return not name.startswith("_")
def classnamefilter(self, name: str) -> bool:
"""Don't allow test classes inside describe"""
return False
def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.name!r}>"
def get_describe_functions(
node: pytest.Item | pytest.Collector,
) -> tuple[types.FunctionType, ...]:
"""Get the functions of the describe blocks enclosing the given node.
The functions are returned in the order of nesting, starting with the
outermost describe block. Reporting plugins can use this function to
access the original describe functions and e.g. their docstrings.
"""
return tuple(
block.funcobj # type: ignore[misc]
for block in node.listchain()
if isinstance(block, DescribeBlock)
)
def pytest_pycollect_makeitem(
collector: pytest.Collector, name: str, obj: object
) -> DescribeBlock | None:
"""Collect items from describe blocks."""
if isinstance(obj, types.FunctionType):
for prefix in collector.config.getini("describe_prefixes"):
if obj.__name__.startswith(prefix):
return DescribeBlock.from_parent(collector, obj)
return None
# Since pytest 8.1, FixtureManager.getfixturedefs takes the node itself
# instead of its node id.
_getfixturedefs_takes_node = pytest.version_tuple >= (8, 1)
@pytest.hookimpl(tryfirst=True)
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
"""Add fixtures declared as describe arguments to the fixture closure.
This makes pytest resolve these fixtures for every test in the block,
including generating parametrized tests for parametrized fixtures.
"""
definition = metafunc.definition
arg_names = [
name
for node in definition.listchain()
if isinstance(node, DescribeBlock)
for name in node.describe_args
]
if not arg_names:
return
fixturemanager = definition.session._fixturemanager
fixtureinfo = definition._fixtureinfo
node = definition if _getfixturedefs_takes_node else definition.nodeid
seen = set(metafunc.fixturenames)
for arg_name in arg_names:
if arg_name in seen:
continue
seen.add(arg_name)
# metafunc.fixturenames is the same list as the names_closure
# of the fixture info used by the test item later
metafunc.fixturenames.append(arg_name)
# the name must also be in initialnames, otherwise it is removed
# again by prune_dependency_tree() when the test is parametrized
# (since pytest 9, FuncFixtureInfo is a frozen dataclass)
object.__setattr__(
fixtureinfo, "initialnames", (*fixtureinfo.initialnames, arg_name)
)
# register the fixture definitions of the added fixture and its
# transitive dependencies, so that parametrized fixtures multiply
# the tests even when they are only used indirectly
pending = [arg_name]
while pending:
dep_name = pending.pop()
fixturedefs = fixturemanager.getfixturedefs(
dep_name,
node, # type: ignore[arg-type] # node id before pytest 8.1
)
if not fixturedefs:
continue # not a fixture (e.g. 'request'), fails at setup
metafunc._arg2fixturedefs[dep_name] = list(fixturedefs)
for dep in fixturedefs[-1].argnames:
if dep not in seen:
seen.add(dep)
metafunc.fixturenames.append(dep)
pending.append(dep)
def gather_describe_cells(item: pytest.Item) -> dict[str, list[types.CellType]]:
"""Collect the argument cells of all describe blocks enclosing a test."""
cells: dict[str, list[types.CellType]] = {}
for node in item.listchain():
if isinstance(node, DescribeBlock):
for name, name_cells in node.describe_cells.items():
all_cells = cells.setdefault(name, [])
for cell in name_cells:
# check identity, equality would compare cell contents
if not any(c is cell for c in all_cells):
all_cells.append(cell)
return cells
INJECTOR_FIXTURE_NAME = "_pytest_describe_inject"
def inject_describe_fixtures(request: pytest.FixtureRequest) -> Iterator[None]:
"""Inject fixture values into the closure cells of describe arguments.
Used as an autouse fixture in describe blocks with arguments, so that
the values are already injected when other fixtures of the block run.
The placeholders are restored when the fixture is torn down.
"""
saved: list[tuple[types.CellType, Any]] = []
for name, name_cells in gather_describe_cells(request.node).items():
value = request.getfixturevalue(name)
for cell in name_cells:
saved.append((cell, cell.cell_contents))
cell.cell_contents = value
yield
for cell, old_value in reversed(saved):
cell.cell_contents = old_value
def make_injector_fixture() -> Any:
"""Create the autouse fixture that injects describe arguments.
The fixture must be created lazily here, because fixtures defined in
the plugin module itself would be registered as global fixtures.
"""
return pytest.fixture(autouse=True, name=INJECTOR_FIXTURE_NAME)(
inject_describe_fixtures
)
def pytest_addoption(parser: pytest.Parser) -> None:
"""Add configuration option describe_prefixes."""
parser.addini(
"describe_prefixes",
type="args",
default=("describe",),
help="prefixes for Python describe function discovery",
)
parser.addini(
"describe_docstrings",
type="bool",
default=False,
help="use docstrings as names for describe blocks",
)