Skip to content

Commit

Permalink
feat: add support for destructuring lambda fixtures
Browse files Browse the repository at this point in the history
  • Loading branch information
theY4Kman committed Jul 14, 2022
1 parent 857023f commit 6f643ab
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 7 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.


## [Unreleased]
### Added
- Add support for destructuring referential tuple lambda fixtures (e.g. `x, y, z = lambda_fixture('a', 'b', 'c')`)
- Add support for destructuring parametrized lambda fixtures (e.g. `a, b, c = lambda_fixture(params=[pytest.param('ayy', 'bee', 'see')])`)


## [1.3.0] — 2022-05-17
Expand Down
95 changes: 91 additions & 4 deletions pytest_lambda/impl.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import functools
import inspect
from types import ModuleType
from typing import Callable, Union
from typing import Any, Callable, List, Optional, Tuple, Union

import pytest
import wrapt
from _pytest.mark import ParameterSet

from .compat import _PytestWrapper

try:
from collections.abc import Iterable, Sized
except ImportError:
from collections import Iterable, Sized

_IDENTITY_LAMBDA_FORMAT = '''
{name} = lambda {argnames}: ({argnames})
'''

_DESTRUCTURED_PARAMETRIZED_LAMBDA_FORMAT = '''
{name} = lambda {source_name}: {source_name}[{index}]
'''


def create_identity_lambda(name, *argnames):
source = _IDENTITY_LAMBDA_FORMAT.format(name=name, argnames=', '.join(argnames))
Expand All @@ -22,26 +32,48 @@ def create_identity_lambda(name, *argnames):
return fixture_func


def create_destructured_parametrized_lambda(name: str, source_name: str, index: int):
source = _DESTRUCTURED_PARAMETRIZED_LAMBDA_FORMAT.format(
name=name, source_name=source_name, index=index
)
context = {}
exec(source, context)

fixture_func = context[name]
return fixture_func


class LambdaFixture(wrapt.ObjectProxy):
# NOTE: pytest won't apply marks unless the markee has a __call__ and a
# __name__ defined.
__name__ = '<lambda-fixture>'

bind: bool
is_async: bool
fixture_kwargs: dict
fixture_func: Callable
has_fixture_func: bool
parent: Union[type, ModuleType]
parent: Optional[Union[type, ModuleType]]
_self_iter: Optional[Iterable]
_self_params_source: Optional['LambdaFixture']

def __init__(
self, fixture_names_or_lambda, *, bind: bool = False, async_: bool, **fixture_kwargs
self,
fixture_names_or_lambda,
*,
bind: bool = False,
async_: bool = False,
_params_source: Optional['LambdaFixture'] = None,
**fixture_kwargs,
):
self.bind = bind
self.is_async = async_
self.fixture_kwargs = fixture_kwargs
self.fixture_func = self._not_implemented
self.has_fixture_func = False
self.parent = None
self._self_iter = None
self._self_params_source = _params_source

#: pytest fixture info definition
self._pytestfixturefunction = pytest.fixture(**fixture_kwargs)
Expand All @@ -51,17 +83,38 @@ def __init__(
self.__pytest_wrapped__ = _PytestWrapper(self)

if fixture_names_or_lambda is not None:
supports_iter = (
not callable(fixture_names_or_lambda)
and not isinstance(fixture_names_or_lambda, str)
and isinstance(fixture_names_or_lambda, Iterable)
)
if supports_iter:
fixture_names_or_lambda = tuple(fixture_names_or_lambda)

self.set_fixture_func(fixture_names_or_lambda)

if supports_iter:
self._self_iter = map(
lambda name: LambdaFixture(name),
fixture_names_or_lambda,
)

elif fixture_kwargs.get('params'):
# Shortcut to allow `lambda_fixture(params=[1,2,3])`
self.set_fixture_func(lambda request: request.param)

params = fixture_kwargs['params'] = tuple(fixture_kwargs['params'])
self._self_iter = _LambdaFixtureParametrizedIterator(self, params)

def __call__(self, *args, **kwargs):
if self.bind:
args = (self.parent,) + args
return self.fixture_func(*args, **kwargs)

def __iter__(self):
if self._self_iter:
return iter(self._self_iter)

def _not_implemented(self):
raise NotImplementedError(
'The fixture_func for this LambdaFixture has not been defined. '
Expand Down Expand Up @@ -131,7 +184,10 @@ def contribute_to_parent(self, parent: Union[type, ModuleType], name: str, **kwa
raise ValueError(f'bind=True cannot be used at the module level. '
f'Please remove this arg in the {name} fixture in {parent.__file__}')

if not self.has_fixture_func:
if self._self_params_source:
self.set_fixture_func(self._not_implemented)

elif not self.has_fixture_func:
# If no fixture definition was passed to lambda_fixture, it's our
# responsibility to define it as the name of the attribute. This is
# handy if ya just wanna force a fixture to be used, e.g.:
Expand Down Expand Up @@ -213,3 +269,34 @@ def _pytestfixturefunction(self, value): self._self__pytestfixturefunction = val
def __pytest_wrapped__(self): return self._self___pytest_wrapped__
@__pytest_wrapped__.setter
def __pytest_wrapped__(self, value): self._self___pytest_wrapped__ = value


class _LambdaFixtureParametrizedIterator:
def __init__(self, source: LambdaFixture, params: Iterable):
self.source = source
self.params = tuple(params)

self.num_params = self._get_param_set_length(self.params[0]) if self.params else 0
self.destructured: List[LambdaFixture] = []

def __iter__(self):
if self.destructured:
raise RuntimeError('Lambda fixtures may only be destructured once.')

for i in range(self.num_params):
child = LambdaFixture(None, _params_source=self.source)
self.destructured.append(child)
yield child

@property
def child_names(self) -> Tuple[str]:
return tuple(child.__name__ for child in self.destructured)

@staticmethod
def _get_param_set_length(param: Union[ParameterSet, Iterable, Any]) -> int:
if isinstance(param, ParameterSet):
return len(param.values)
elif isinstance(param, Sized) and not isinstance(param, (str, bytes)):
return len(param)
else:
return 1
59 changes: 56 additions & 3 deletions pytest_lambda/plugin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import inspect
from typing import List, Tuple
from typing import List, Sequence, Set, Tuple

from _pytest.python import Module
import pytest
from _pytest.mark import Mark, ParameterSet
from _pytest.python import Metafunc, Module

from pytest_lambda.impl import LambdaFixture
from pytest_lambda.impl import LambdaFixture, _LambdaFixtureParametrizedIterator


def pytest_collectstart(collector):
Expand All @@ -26,3 +28,54 @@ def process_lambda_fixtures(parent):
attr.contribute_to_parent(parent, name)

return parent


@pytest.hookimpl(tryfirst=True)
def pytest_generate_tests(metafunc: Metafunc) -> None:
"""Parametrize all tests using destructured parametrized lambda fixtures
This is what powers things like:
a, b, c = lambda_fixture(params=[
pytest.param(1, 2, 3)
])
def test_my_thing(a, b, c):
assert a < b < c
"""
param_sources: Set[LambdaFixture] = set()

for argname in metafunc.fixturenames:
# Get the FixtureDefs for the argname.
fixture_defs = metafunc._arg2fixturedefs.get(argname)
if not fixture_defs:
# Will raise FixtureLookupError at setup time if not parametrized somewhere
# else (e.g @pytest.mark.parametrize)
continue

for fixturedef in reversed(fixture_defs):
param_source = getattr(fixturedef.func, '_self_params_source', None)
if param_source:
param_sources.add(param_source)

if param_sources:
requested_fixturenames = set(metafunc.fixturenames)

for param_source in param_sources:
params_iter = param_source._self_iter
assert isinstance(params_iter, _LambdaFixtureParametrizedIterator)

# TODO(zk): skip parametrization for args already parametrized by @mark.parametrize
# XXX(zk): is there a way around falsifying the requested fixturenames to avoid "uses no argument" error?
for child_name in params_iter.child_names:
if child_name not in requested_fixturenames:
metafunc.fixturenames.append(child_name)
requested_fixturenames.add(child_name)

metafunc.parametrize(
params_iter.child_names,
param_source.fixture_kwargs['params'],
scope=param_source.fixture_kwargs.get('scope'),
ids=param_source.fixture_kwargs.get('ids'),
)
31 changes: 31 additions & 0 deletions tests/test_module.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from pytest_lambda import lambda_fixture, static_fixture


Expand Down Expand Up @@ -40,6 +42,35 @@ def it_processes_toplevel_tuple_lambda_fixture(abc):
assert expected == actual


x, y, z = lambda_fixture('a', 'b', 'c')


def it_processes_toplevel_destructured_tuple_lambda_fixture(x, y, z):
expected = ('a', 'b', 'c')
actual = (x, y, z)
assert expected == actual


pa, pb, pc, pd = lambda_fixture(params=[
pytest.param('alfalfa', 'better', 'dolt', 'gamer'),
])


def it_processes_toplevel_destructured_parametrized_lambda_fixture(pa, pb, pc, pd):
expected = ('alfalfa', 'better', 'dolt', 'gamer')
actual = (pa, pb, pc, pd)
assert expected == actual


destructured_id = lambda_fixture(params=[
pytest.param('muffin', id='muffin'),
])


def it_uses_ids_from_destructured_parametrized_lambda_fixture(destructured_id, request):
assert destructured_id in request.node.callspec.id


class TestClass:
a = lambda_fixture()

Expand Down

0 comments on commit 6f643ab

Please sign in to comment.