22
33from __future__ import annotations
44
5+ import inspect
56import sys
67import types
7- from collections .abc import Callable , Iterable
8- from typing import Any
8+ from collections .abc import Callable , Iterable , Iterator
9+ from typing import Any , NoReturn
910
1011import pytest
1112
@@ -27,6 +28,84 @@ def is_fixture_function_definition(obj: object) -> bool:
2728 return isinstance (obj , FixtureFunctionDefinition )
2829
2930
31+ class DescribeArgumentError (Exception ):
32+ """Error raised when a describe argument is used outside of tests."""
33+
34+
35+ class DescribeArgument :
36+ """Placeholder for a fixture passed as argument to a describe block.
37+
38+ Describe blocks run at collection time, when fixtures are not yet
39+ available. The real fixture value is injected into the closure cells
40+ before each test runs. Using the placeholder itself raises an error.
41+ """
42+
43+ __slots__ = ("name" ,)
44+
45+ def __init__ (self , name : str ) -> None :
46+ self .name = name
47+
48+ def __repr__ (self ) -> str :
49+ return (
50+ f"<describe argument { self .name !r} "
51+ " (the fixture is only available inside tests)>"
52+ )
53+
54+ def _used (self , * args : Any , ** kwargs : Any ) -> NoReturn :
55+ raise DescribeArgumentError (
56+ f"The argument { self .name !r} is a placeholder for a fixture"
57+ " that is only available inside the tests of the describe"
58+ " block, it cannot be used in the describe block itself."
59+ )
60+
61+ __call__ = __getattr__ = __getitem__ = __setitem__ = __delitem__ = _used
62+ __iter__ = __contains__ = __len__ = __bool__ = _used
63+ __eq__ = __ne__ = __lt__ = __le__ = __gt__ = __ge__ = _used
64+ __add__ = __sub__ = __mul__ = __truediv__ = _used
65+ __hash__ = object .__hash__
66+
67+
68+ def get_describe_args (func : Callable [..., Any ]) -> tuple [str , ...]:
69+ """Get the fixture names a describe block declares as parameters.
70+
71+ Mirroring how pytest determines fixture names for test functions,
72+ only mandatory (non-defaulted) named parameters are considered.
73+ """
74+ return tuple (
75+ name
76+ for name , param in inspect .signature (func ).parameters .items ()
77+ if param .kind in (param .POSITIONAL_OR_KEYWORD , param .KEYWORD_ONLY )
78+ and param .default is param .empty
79+ )
80+
81+
82+ def find_argument_cells (namespace : dict [str , Any ]) -> dict [str , list [types .CellType ]]:
83+ """Find closure cells holding describe arguments in collected functions.
84+
85+ Cells are shared between all nested functions referencing the same
86+ outer variable, but the same argument name can appear in multiple
87+ cells, e.g. when a describe block and an imported shared behavior
88+ both declare an argument with the same name.
89+ """
90+ cells : dict [str , list [types .CellType ]] = {}
91+ for obj in namespace .values ():
92+ # Unwrap fixture function definitions (pytest >= 8.4)
93+ func = inspect .unwrap (obj ) if is_fixture_function_definition (obj ) else obj
94+ if not isinstance (func , types .FunctionType ):
95+ continue
96+ for cell in func .__closure__ or ():
97+ try :
98+ contents = cell .cell_contents
99+ except ValueError : # pragma: no cover (empty cell)
100+ continue
101+ if isinstance (contents , DescribeArgument ):
102+ name_cells = cells .setdefault (contents .name , [])
103+ # check identity, equality would compare cell contents
104+ if not any (c is cell for c in name_cells ):
105+ name_cells .append (cell )
106+ return cells
107+
108+
30109def trace_function (
31110 func : Callable [..., Any ], * args : Any , ** kwargs : Any
32111) -> dict [str , Any ]:
@@ -54,7 +133,9 @@ def _trace_func(
54133 return f_locals
55134
56135
57- def make_module_from_function (func : types .FunctionType ) -> types .ModuleType :
136+ def make_module_from_function (
137+ func : types .FunctionType , args : tuple [str , ...] = ()
138+ ) -> types .ModuleType :
58139 """Evaluate the local scope of a function as if it was a module."""
59140 module = types .ModuleType (func .__name__ )
60141
@@ -64,8 +145,17 @@ def make_module_from_function(func: types.FunctionType) -> types.ModuleType:
64145 for shared_func in getattr (func , "_behaves_like" , ()):
65146 module .__dict__ .update (evaluate_shared_behavior (shared_func ))
66147
67- # Import children
68- module .__dict__ .update (trace_function (func ))
148+ # Import children, passing placeholders for declared fixture arguments.
149+ # Placeholders are removed from the namespace; note that this includes
150+ # arguments of outer describe blocks appearing here as free variables.
151+ funclocals = {
152+ name : obj
153+ for name , obj in trace_function (
154+ func , ** {name : DescribeArgument (name ) for name in args }
155+ ).items ()
156+ if not isinstance (obj , DescribeArgument )
157+ }
158+ module .__dict__ .update (funclocals )
69159 return module
70160
71161
@@ -75,7 +165,11 @@ def evaluate_shared_behavior(func: types.FunctionType) -> dict[str, Any]:
75165 shared_functions : dict [str , Any ] = func ._shared_functions # type: ignore[attr-defined]
76166 except AttributeError :
77167 shared_functions = {}
78- for name , obj in trace_function (func ).items ():
168+ funclocals = trace_function (
169+ func ,
170+ ** {name : DescribeArgument (name ) for name in get_describe_args (func )},
171+ )
172+ for name , obj in funclocals .items ():
79173 # Only functions and fixtures are relevant here
80174 if not is_function_or_fixture (obj ):
81175 continue
@@ -96,6 +190,9 @@ class DescribeBlock(pytest.Module):
96190 # of type FunctionType, so we need to ignore some errors when using it.
97191 funcobj : types .FunctionType
98192
193+ describe_args : tuple [str , ...]
194+ describe_cells : dict [str , list [types .CellType ]]
195+
99196 @classmethod
100197 def from_parent ( # type: ignore[override]
101198 cls , parent : pytest .Collector , obj : types .FunctionType
@@ -108,6 +205,8 @@ def from_parent( # type: ignore[override]
108205 )
109206 self .name = name
110207 self .funcobj = obj # type: ignore[assignment]
208+ self .describe_args = get_describe_args (obj )
209+ self .describe_cells = {}
111210 return self
112211
113212 def collect (self ) -> Iterable [pytest .Item | pytest .Collector ]:
@@ -121,7 +220,21 @@ def _getobj(self) -> types.ModuleType:
121220
122221 def _importtestmodule (self ) -> types .ModuleType :
123222 """Import a describe block as if it was a module"""
124- module = make_module_from_function (self .funcobj ) # type: ignore[arg-type]
223+ module = make_module_from_function (
224+ self .funcobj , # type: ignore[arg-type]
225+ self .describe_args ,
226+ )
227+ # Arguments of imported shared behaviors are treated like
228+ # arguments of the describe block that imports the behavior.
229+ for shared_func in getattr (self .funcobj , "_behaves_like" , ()):
230+ for name in get_describe_args (shared_func ):
231+ if name not in self .describe_args :
232+ self .describe_args += (name ,)
233+ self .describe_cells = find_argument_cells (module .__dict__ )
234+ if self .describe_args :
235+ # Provide the autouse fixture that injects the fixture values
236+ # into the closure cells before each test in this block.
237+ module .__dict__ [INJECTOR_FIXTURE_NAME ] = make_injector_fixture ()
125238 self .own_markers = getattr (self .funcobj , "pytestmark" , [])
126239 return module
127240
@@ -151,6 +264,110 @@ def pytest_pycollect_makeitem(
151264 return None
152265
153266
267+ # Since pytest 8.1, FixtureManager.getfixturedefs takes the node itself
268+ # instead of its node id.
269+ _getfixturedefs_takes_node = pytest .version_tuple >= (8 , 1 )
270+
271+
272+ @pytest .hookimpl (tryfirst = True )
273+ def pytest_generate_tests (metafunc : pytest .Metafunc ) -> None :
274+ """Add fixtures declared as describe arguments to the fixture closure.
275+
276+ This makes pytest resolve these fixtures for every test in the block,
277+ including generating parametrized tests for parametrized fixtures.
278+ """
279+ definition = metafunc .definition
280+ arg_names = [
281+ name
282+ for node in definition .listchain ()
283+ if isinstance (node , DescribeBlock )
284+ for name in node .describe_args
285+ ]
286+ if not arg_names :
287+ return
288+ fixturemanager = definition .session ._fixturemanager
289+ fixtureinfo = definition ._fixtureinfo
290+ node = definition if _getfixturedefs_takes_node else definition .nodeid
291+ seen = set (metafunc .fixturenames )
292+ for arg_name in arg_names :
293+ if arg_name in seen :
294+ continue
295+ seen .add (arg_name )
296+ # metafunc.fixturenames is the same list as the names_closure
297+ # of the fixture info used by the test item later
298+ metafunc .fixturenames .append (arg_name )
299+ # the name must also be in initialnames, otherwise it is removed
300+ # again by prune_dependency_tree() when the test is parametrized
301+ # (since pytest 9, FuncFixtureInfo is a frozen dataclass)
302+ object .__setattr__ (
303+ fixtureinfo , "initialnames" , (* fixtureinfo .initialnames , arg_name )
304+ )
305+ # register the fixture definitions of the added fixture and its
306+ # transitive dependencies, so that parametrized fixtures multiply
307+ # the tests even when they are only used indirectly
308+ pending = [arg_name ]
309+ while pending :
310+ dep_name = pending .pop ()
311+ fixturedefs = fixturemanager .getfixturedefs (
312+ dep_name ,
313+ node , # type: ignore[arg-type] # node id before pytest 8.1
314+ )
315+ if not fixturedefs :
316+ continue # not a fixture (e.g. 'request'), fails at setup
317+ metafunc ._arg2fixturedefs [dep_name ] = list (fixturedefs )
318+ for dep in fixturedefs [- 1 ].argnames :
319+ if dep not in seen :
320+ seen .add (dep )
321+ metafunc .fixturenames .append (dep )
322+ pending .append (dep )
323+
324+
325+ def gather_describe_cells (item : pytest .Item ) -> dict [str , list [types .CellType ]]:
326+ """Collect the argument cells of all describe blocks enclosing a test."""
327+ cells : dict [str , list [types .CellType ]] = {}
328+ for node in item .listchain ():
329+ if isinstance (node , DescribeBlock ):
330+ for name , name_cells in node .describe_cells .items ():
331+ all_cells = cells .setdefault (name , [])
332+ for cell in name_cells :
333+ # check identity, equality would compare cell contents
334+ if not any (c is cell for c in all_cells ):
335+ all_cells .append (cell )
336+ return cells
337+
338+
339+ INJECTOR_FIXTURE_NAME = "_pytest_describe_inject"
340+
341+
342+ def inject_describe_fixtures (request : pytest .FixtureRequest ) -> Iterator [None ]:
343+ """Inject fixture values into the closure cells of describe arguments.
344+
345+ Used as an autouse fixture in describe blocks with arguments, so that
346+ the values are already injected when other fixtures of the block run.
347+ The placeholders are restored when the fixture is torn down.
348+ """
349+ saved : list [tuple [types .CellType , Any ]] = []
350+ for name , name_cells in gather_describe_cells (request .node ).items ():
351+ value = request .getfixturevalue (name )
352+ for cell in name_cells :
353+ saved .append ((cell , cell .cell_contents ))
354+ cell .cell_contents = value
355+ yield
356+ for cell , old_value in reversed (saved ):
357+ cell .cell_contents = old_value
358+
359+
360+ def make_injector_fixture () -> Any :
361+ """Create the autouse fixture that injects describe arguments.
362+
363+ The fixture must be created lazily here, because fixtures defined in
364+ the plugin module itself would be registered as global fixtures.
365+ """
366+ return pytest .fixture (autouse = True , name = INJECTOR_FIXTURE_NAME )(
367+ inject_describe_fixtures
368+ )
369+
370+
154371def pytest_addoption (parser : pytest .Parser ) -> None :
155372 """Add configuration option describe_prefixes."""
156373 parser .addini (
0 commit comments