I find myself frequently using the following pattern for pytest fixtures:
@pytest.fixture()
def make_stuff(fix1, fix2):
def _fn(arg1, arg2):
... do something ...
return _fn
...
def test_me(make_stuff):
make_stuff(1, 2)
My idea was to create a maker
decorator to DRY it out.
@pytest.fixture()
@pytest.maker(fix1, fix2)
def make_stuff(arg1, arg2):
... do something ...
I don't see a good way to plug this idea into the pytest framework. Ideas would be appreciated.
What makes your idea particularly tricky is the fact that you're trying to turn fix1
and fix2
, originally available to the inner function through closure:
@pytest.fixture
def make_stuff(fix1, fix2):
def _fn(arg1, arg2):
print(fix1) # fix1 is bound the argument fix1 passed to make_stuff
return _fn
into arguments that get passed to a decorator factory, making them inaccessible within the decorated function:
@pytest.fixture
def fix1():
...
@pytest.fixture
@magic_decorator(fix1, fix2)
def make_stuff(arg1, arg2):
print(fix1) # fix1 is now bound to the global function fix1--not what you want
To make arguments passed to the decorator factory available to the decorated function, one approach would be to programmatically refactor the latter pattern of code into the former through manipulation of AST, which can be done by parsing the function source into AST and using ast.NodeTransformer
with a function visitor to enclose the decorated function in an outer function defined with arguments named the same as those passed to the decorator factory. Once transformed, compile the AST into a code object and execute it to produce the desired function object to replace the decorated function:
import ast
import inspect
from textwrap import dedent
class AddClosure(ast.NodeTransformer):
def __init__(self, names):
self.names = names
def visit_FunctionDef(self, node):
node.decorator_list = [
d for d in node.decorator_list
if not isinstance(d, ast.Call) or d.func.id != 'with_fixtures'
]
self.generic_visit(node)
return ast.FunctionDef(
name=node.name,
args=ast.arguments(
args=[ast.arg(arg=name) for name in self.names],
posonlyargs=[], kwonlyargs=[], kw_defaults=[], defaults=[]
),
body=[node, ast.Return(value=ast.Name(id=node.name, ctx=ast.Load()))],
decorator_list = []
)
def with_fixtures(*fixtures):
def decorator(func):
tree = AddClosure(f.__name__ for f in fixtures).visit(
ast.parse(dedent(inspect.getsource(func))))
ast.fix_missing_locations(tree)
scope = {}
exec(compile(tree, inspect.getfile(func), "exec"), func.__globals__, scope)
return pytest.fixture(scope[func.__name__])
return decorator
so that:
@pytest.fixture
def fix1():
return 'a'
@pytest.fixture
def fix2():
return 'b'
@with_fixtures(fix1, fix2)
def make_stuff(arg1, arg2):
return fix1, fix2, arg1, arg2
def test_make_stuff(make_stuff):
assert make_stuff(1, 2) == ('a', 'b', 1, 2)
would pass the test.
Demo (switch to the Shell tab and run pytest main.py
):
https://replit.com/@blhsing1/DarkorangeDimpledGreenware