Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proof of concept of fixtures as describe block funcargs #39

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 125 additions & 3 deletions pytest_describe/plugin.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
68 changes: 68 additions & 0 deletions test/test_fixture_injection.py
Original file line number Diff line number Diff line change
@@ -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)