From df956ec23f95008a5503b20c3a9d2f23b3dfb5fd Mon Sep 17 00:00:00 2001 From: Ruben Opdebeeck <15186467+ROpdebee@users.noreply.github.com> Date: Thu, 19 Aug 2021 00:23:26 +0200 Subject: [PATCH] [WIP] PoC of fixture injection in describe blocks --- pytest_describe/plugin.py | 128 ++++++++++++++++++++++++++++++++- test/test_fixture_injection.py | 68 ++++++++++++++++++ 2 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 test/test_fixture_injection.py diff --git a/pytest_describe/plugin.py b/pytest_describe/plugin.py index 1779f14..224c8c9 100644 --- a/pytest_describe/plugin.py +++ b/pytest_describe/plugin.py @@ -1,10 +1,125 @@ +import contextlib +import dis +import functools +import inspect import sys import types +from collections import namedtuple from _pytest.python import PyCollector +import pprint + +# Dummy objects that are passed as arguments to the describe blocks and are +# later used to determine which fixture to inject into the test function's +# closure. +InjectFixture = namedtuple('_InjectFixture', ['name']) + + +def accesses_arguments(funcobj): + """Inspect a function's bytecode to determine if it uses its parameters. + + Used to determine whether the describe block itself may attempt to use the + dummy arguments we pass to it. Note that this may produce false positives. + """ + # LOAD_DEREF is used to access free variables from a closure, so parameters + # from an outer function. LOAD_FAST is used to load parameters of the + # function itself. + parent_params = { + arg.name for arg in getattr(funcobj, '_parent_fixture_args', set())} + params = set(inspect.signature(funcobj).parameters) | parent_params + + return any( + instr.opname in ('LOAD_DEREF', 'LOAD_FAST') and instr.argval in params + for instr in dis.get_instructions(funcobj)) + + +def raise_if_cannot_change_closure(): + """Raise if we cannot change the closure in this Python version.""" + def outer(x): + def inner(): + return x + return inner + inner = outer(1) + try: + inner.__closure__[0].cel_contents = 2 + except err: # Not sure which exception it could be + raise PyCollector.CollectError( + 'Passing fixture names to describe blocks is not supported in this' + 'Python version') from err + + if inner() != 2: + raise PyCollector.CollectError( + 'Passing fixture names to describe blocks is not supported in this' + 'Python version') + + +def construct_injected_fixture_args(funcobj): + """Construct a set of dummy arguments that mark fixture injections.""" + # TODO: How do we handle kw-only args, args with defaults? + return set(map(InjectFixture, inspect.signature(funcobj).parameters)) + + +def inject_fixtures(func, fixture_args): + if not isinstance(func, types.FunctionType): + return func + + if hasattr(func, '_pytestfixturefunction'): + # TODO: How should we handle fixtures? + return func + + if func.__name__.startswith('describe_'): + # FIXME: Allow customisation of describe prefix + return func + + # Store all fixture args in all local functions. This is necessary for + # nested describe blocks. + func._parent_fixture_args = fixture_args + + @contextlib.contextmanager + def _temp_change_cell(cell, new_value): + old_value = cell.cell_contents + cell.cell_contents = new_value + yield + cell.cell_contents = old_value + + # Wrap the function in an extended function that takes the fixtures + # and updates the closure + def wrapped(request, **kwargs): + # Use the request fixture to get fixture values, and either feed those + # as parameters or inject them into the closure + with contextlib.ExitStack() as exit_stack: + for cell in (func.__closure__ or []): + if not isinstance(cell.cell_contents, InjectFixture): + continue + fixt_value = request.getfixturevalue(cell.cell_contents.name) + exit_stack.enter_context(_temp_change_cell(cell, fixt_value)) + + direct_params = {} + for param in inspect.signature(func).parameters: + if param in kwargs: + direct_params[param] = kwargs[param] + else: + direct_params[param] = request.getfixturevalue(param) + + func(**direct_params) + + if hasattr(func, 'pytestmark'): + wrapped.pytestmark = func.pytestmark + + return wrapped + def trace_function(funcobj, *args, **kwargs): - """Call a function, and return its locals""" + """Call a function, and return its locals, wrapped to inject fixtures""" + if accesses_arguments(funcobj): + # Since describe blocks run during test collection rather than + # execution, fixture results aren't available. Although dereferencing + # our dummy objects will not directly lead to an error, it would surely + # lead to unexpected results. + raise PyCollector.CollectError( + 'Describe blocks must not directly dereference their fixture ' + 'arguments') + funclocals = {} def _tracefunc(frame, event, arg): @@ -13,13 +128,19 @@ def _tracefunc(frame, event, arg): if event == 'return': funclocals.update(frame.f_locals) + direct_fixture_args = construct_injected_fixture_args(funcobj) + parent_fixture_args = getattr(funcobj, '_parent_fixture_args', set()) + sys.setprofile(_tracefunc) try: - funcobj(*args, **kwargs) + # TODO: Are *args and **kwargs necessary here? + funcobj(*direct_fixture_args, *args, **kwargs) finally: sys.setprofile(None) - return funclocals + return { + name: inject_fixtures(obj, direct_fixture_args | parent_fixture_args) + for name, obj in funclocals.items()} def make_module_from_function(funcobj): @@ -40,6 +161,7 @@ def make_module_from_function(funcobj): def evaluate_shared_behavior(funcobj): if not hasattr(funcobj, '_shared_functions'): funcobj._shared_functions = {} + # TODO: What to do with fixtures in shared behavior closures? for name, obj in trace_function(funcobj).items(): # Only functions are relevant here if not isinstance(obj, types.FunctionType): diff --git a/test/test_fixture_injection.py b/test/test_fixture_injection.py new file mode 100644 index 0000000..7b0b216 --- /dev/null +++ b/test/test_fixture_injection.py @@ -0,0 +1,68 @@ +import py +from util import assert_outcomes + +from pytest_describe.plugin import InjectFixture, accesses_arguments + + +def test_accesses_arguments_params(): + def f(x): + x + + assert accesses_arguments(f) + + +def test_accesses_arguments_closure(): + def outer(x): + def inner(): + x + return inner + inner = outer(1) + inner._parent_fixture_args = {InjectFixture('x')} + + assert not accesses_arguments(outer) + assert accesses_arguments(inner) + + +def test_accesses_arguments_locals(): + def outer(): + x = 1 + x + print(x) + + assert not accesses_arguments(outer) + + +def test_accesses_arguments_outer_locals(): + def outer(): + x = 1 + x + def inner(): + x + return inner + + assert not accesses_arguments(outer) + assert not accesses_arguments(outer()) + + +def test_inject_fixtures(testdir): + a_dir = testdir.mkpydir('a_dir') + a_dir.join('test_a.py').write(py.code.Source(""" + import pytest + + @pytest.fixture + def thing(): + return 42 + + def describe_something(thing): + + def thing_is_not_43(): + assert thing != 43 + + def describe_nested_block(): + + def thing_is_42(): + assert thing == 42 + """)) + + result = testdir.runpytest() + assert_outcomes(result, passed=2)