Skip to content

Commit 36ed73b

Browse files
committed
New decorator @pytest_fixture_plus allows to use several @pytest.mark.parametrize on a fixture. Therefore one can use multiple @cases_data decorators, too. Fixes #19.
Note: this is a temporary feature, that will be removed if/when pytest supports it, see pytest-dev/pytest#3960.
1 parent ff97ed2 commit 36ed73b

File tree

6 files changed

+431
-37
lines changed

6 files changed

+431
-37
lines changed

docs/index.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,18 +97,21 @@ Once you have done these three steps, executing `pytest` will run your test func
9797

9898
### d- Case fixtures
9999

100-
You might be concerned that case data is gathered inside test execution. Indeed gathering case data is not part of the test *per se*. Besides if you use for example [pytest-harvest](https://smarie.github.io/python-pytest-harvest/) to benchmark your tests durations, you may want the test duration to be computed without acccounting for the data retrieval time (especially if you decide to add some caching mechanism as explained [here](https://smarie.github.io/python-pytest-cases/usage/advanced/#caching)).
100+
You might be concerned that case data is gathered or created *during* test execution.
101101

102-
The answer is simple: instead of parametrizing your test function, rather create a parametrized fixture:
102+
Indeed creating or collecting case data is not part of the test *per se*. Besides, if you benchmark your tests durations (for example with [pytest-harvest](https://smarie.github.io/python-pytest-harvest/)), you may want the test duration to be computed without acccounting for the data retrieval time - especially if you decide to add some caching mechanism as explained [here](https://smarie.github.io/python-pytest-cases/usage/advanced/#caching).
103+
104+
It might therefore be more interesting for you to parametrize **case fixtures** instead of parametrizing your test function:
103105

104106
```python
105-
from pytest_cases import cases_fixture
107+
from pytest_cases import pytest_fixture_plus, cases_data
106108
from example import foo
107109

108110
# import the module containing the test cases
109111
import test_foo_cases
110112

111-
@cases_fixture(module=test_foo_cases)
113+
@pytest_fixture_plus
114+
@cases_data(module=test_foo_cases)
112115
def inputs(case_data):
113116
""" Example fixture that is automatically parametrized with @cases_data """
114117
# retrieve case data
@@ -119,8 +122,13 @@ def test_foo(inputs):
119122
foo(**inputs)
120123
```
121124

125+
In the above example, the `test_foo` test does not spend time collecting or generating data. When it is executed, it receives the required data directly as `inputs`. The test case creation instead happens when each `inputs` fixture instance is created by `pytest` - this is done in a separate pytest phase (named "setup"), and therefore is not counted in the test duration.
126+
122127
Note: you can still use `request` in your fixture's signature if you wish to.
123128

129+
!!! note "`@pytest_fixture_plus` deprecation if/when `@pytest.fixture` supports `@pytest.mark.parametrize`"
130+
The ability for pytest fixtures to support the `@pytest.mark.parametrize` annotation is a feature that clearly belongs to `pytest` scope, and has been [requested already](https://github.com/pytest-dev/pytest/issues/3960). It is therefore expected that `@pytest_fixture_plus` will be deprecated in favor of `@pytest_fixture` if/when the `pytest` team decides to add the proposed feature. As always, deprecation will happen slowly across versions (at least two minor, or one major version update) so as for users to have the time to update their code bases.
131+
124132
## Usage - 'True' test cases
125133

126134
#### a- Case functions update

pytest_cases/__init__.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
except ImportError:
66
pass
77

8-
from pytest_cases.main import cases_data, CaseDataGetter, cases_fixture, \
8+
from pytest_cases.main import cases_data, CaseDataGetter, cases_fixture, pytest_fixture_plus, \
99
unfold_expected_err, get_all_cases, extract_cases_from_module, THIS_MODULE
1010

1111
__all__ = [
12-
# the 2 submodules
13-
'main', 'case_funcs',
12+
# the 3 submodules
13+
'main', 'case_funcs', 'common',
1414
# all symbols imported above
15-
'cases_data', 'CaseData', 'CaseDataGetter', 'cases_fixture', 'unfold_expected_err', 'get_all_cases',
15+
'cases_data', 'CaseData', 'CaseDataGetter', 'cases_fixture', 'pytest_fixture_plus',
16+
'unfold_expected_err', 'get_all_cases',
1617
'extract_cases_from_module',
1718
'case_name', 'Given', 'ExpectedNormal', 'ExpectedError',
1819
'test_target', 'case_tags', 'THIS_MODULE', 'cases_generator', 'MultipleStepsCaseData'

pytest_cases/common.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
try: # python 3.3+
2+
from inspect import signature
3+
except ImportError:
4+
from funcsigs import signature
5+
6+
import pytest
7+
8+
9+
# Create a symbol that will work to create a fixture containing 'yield', whatever the pytest version
10+
# Note: if more prevision is needed, use if LooseVersion(pytest.__version__) < LooseVersion('3.0.0')
11+
if int(pytest.__version__.split('.', 1)[0]) < 3:
12+
yield_fixture = pytest.yield_fixture
13+
else:
14+
yield_fixture = pytest.fixture
15+
16+
17+
class _LegacyMark:
18+
__slots__ = "args", "kwargs"
19+
20+
def __init__(self, *args, **kwargs):
21+
self.args = args
22+
self.kwargs = kwargs
23+
24+
25+
class _ParametrizationMark:
26+
"""
27+
Represents the information required by `decorate_pytest_fixture_plus` to work.
28+
"""
29+
__slots__ = "param_names", "param_values", "param_ids"
30+
31+
def __init__(self, mark):
32+
bound = get_parametrize_signature().bind(*mark.args, **mark.kwargs)
33+
self.param_names = bound.arguments['argnames'].split(',')
34+
self.param_values = bound.arguments['argvalues']
35+
try:
36+
bound.apply_defaults()
37+
self.param_ids = bound.arguments['ids']
38+
except AttributeError:
39+
# can happen if signature is from funcsigs so we have to apply ourselves
40+
self.param_ids = bound.arguments.get('ids', None)
41+
42+
43+
def get_pytest_parametrize_marks(f):
44+
"""
45+
Returns the @pytest.mark.parametrize marks associated with a function
46+
47+
:param f:
48+
:return: a tuple containing all 'parametrize' marks
49+
"""
50+
# pytest > 3.2.0
51+
marks = getattr(f, 'pytestmark', None)
52+
if marks is not None:
53+
return tuple(_ParametrizationMark(m) for m in marks if m.name == 'parametrize')
54+
else:
55+
# older versions
56+
mark_info = getattr(f, 'parametrize', None)
57+
if mark_info is not None:
58+
# mark_info.args contains a list of (name, values)
59+
return tuple(_ParametrizationMark(_LegacyMark(mark_info.args[2*i], mark_info.args[2*i + 1],
60+
**mark_info.kwargs))
61+
for i in range(len(mark_info.args) // 2))
62+
else:
63+
return ()
64+
65+
66+
def _pytest_mark_parametrize(argnames, argvalues, ids=None):
67+
""" Fake method to have a reference signature of pytest.mark.parametrize"""
68+
pass
69+
70+
71+
def get_parametrize_signature():
72+
"""
73+
74+
:return: a reference signature representing
75+
"""
76+
return signature(_pytest_mark_parametrize)

pytest_cases/main.py

Lines changed: 197 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33

44
import sys
55
from abc import abstractmethod, ABCMeta
6-
from inspect import getmembers, isgeneratorfunction
6+
from distutils.version import LooseVersion
7+
from inspect import getmembers, isgeneratorfunction, getmodule
78

9+
from pytest_cases.common import yield_fixture, get_pytest_parametrize_marks
810
from pytest_cases.decorator_hack import my_decorate
911

1012
try: # type hints, python 3+
@@ -134,6 +136,14 @@ def cases_fixture(cases=None, # type: Union[Callable[[Any]
134136
**kwargs
135137
):
136138
"""
139+
DEPRECATED - use double annotation `@pytest_fixture_plus` + `@cases_data` instead
140+
141+
```python
142+
@pytest_fixture_plus
143+
@cases_data(module=xxx)
144+
def my_fixture(case_data)
145+
```
146+
137147
Decorates a function so that it becomes a parametrized fixture.
138148
139149
The fixture will be automatically parametrized with all cases listed in module `module`, or with
@@ -190,39 +200,198 @@ def foo_fixture(request):
190200
`module`. It both `has_tag` and `filter` are set, both will be applied in sequence.
191201
:return:
192202
"""
193-
def fixture_decorator(fixture_func):
194-
"""
195-
The generated fixture function decorator.
196-
197-
:param fixture_func:
198-
:return:
199-
"""
200-
# First list all cases according to user preferences
201-
_cases = get_all_cases(cases, module, fixture_func, has_tag, filter)
203+
def _double_decorator(f):
204+
# apply @cases_data (that will translate to a @pytest.mark.parametrize)
205+
parametrized_f = cases_data(cases=cases, module=module,
206+
case_data_argname=case_data_argname, has_tag=has_tag, filter=filter)(f)
207+
# apply @pytest_fixture_plus
208+
return pytest_fixture_plus(**kwargs)(parametrized_f)
209+
210+
return _double_decorator
211+
212+
213+
def pytest_fixture_plus(scope="function",
214+
params=None,
215+
autouse=False,
216+
ids=None,
217+
name=None,
218+
**kwargs):
219+
""" (return a) decorator to mark a fixture factory function.
220+
221+
Identical to `@pytest.fixture` decorator, except that it supports multi-parametrization with
222+
`@pytest.mark.parametrize` as requested in https://github.com/pytest-dev/pytest/issues/3960.
223+
224+
:param scope: the scope for which this fixture is shared, one of
225+
"function" (default), "class", "module" or "session".
226+
:param params: an optional list of parameters which will cause multiple
227+
invocations of the fixture function and all of the tests
228+
using it.
229+
:param autouse: if True, the fixture func is activated for all tests that
230+
can see it. If False (the default) then an explicit
231+
reference is needed to activate the fixture.
232+
:param ids: list of string ids each corresponding to the params
233+
so that they are part of the test id. If no ids are provided
234+
they will be generated automatically from the params.
235+
:param name: the name of the fixture. This defaults to the name of the
236+
decorated function. If a fixture is used in the same module in
237+
which it is defined, the function name of the fixture will be
238+
shadowed by the function arg that requests the fixture; one way
239+
to resolve this is to name the decorated function
240+
``fixture_<fixturename>`` and then use
241+
``@pytest.fixture(name='<fixturename>')``.
242+
:param kwargs: other keyword arguments for `@pytest.fixture`
243+
"""
202244

203-
# old: use id getter function : cases_ids = str
204-
# new: hardcode the case ids, safer (?) in case this is mixed with another fixture
205-
cases_ids = [str(c) for c in _cases]
245+
if callable(scope) and params is None and autouse is False:
246+
# direct decoration without arguments
247+
return decorate_pytest_fixture_plus(scope)
248+
else:
249+
# arguments have been provided
250+
def _decorator(f):
251+
return decorate_pytest_fixture_plus(f,
252+
scope=scope, params=params, autouse=autouse, ids=ids, name=name,
253+
**kwargs)
254+
return _decorator
255+
256+
257+
def decorate_pytest_fixture_plus(fixture_func,
258+
scope="function",
259+
params=None,
260+
autouse=False,
261+
ids=None,
262+
name=None,
263+
**kwargs):
264+
"""
265+
Manual decorator equivalent to `@pytest_fixture_plus`
266+
267+
:param fixture_func: the function to decorate
268+
269+
:param scope: the scope for which this fixture is shared, one of
270+
"function" (default), "class", "module" or "session".
271+
:param params: an optional list of parameters which will cause multiple
272+
invocations of the fixture function and all of the tests
273+
using it.
274+
:param autouse: if True, the fixture func is activated for all tests that
275+
can see it. If False (the default) then an explicit
276+
reference is needed to activate the fixture.
277+
:param ids: list of string ids each corresponding to the params
278+
so that they are part of the test id. If no ids are provided
279+
they will be generated automatically from the params.
280+
:param name: the name of the fixture. This defaults to the name of the
281+
decorated function. If a fixture is used in the same module in
282+
which it is defined, the function name of the fixture will be
283+
shadowed by the function arg that requests the fixture; one way
284+
to resolve this is to name the decorated function
285+
``fixture_<fixturename>`` and then use
286+
``@pytest.fixture(name='<fixturename>')``.
287+
:param kwargs:
288+
:return:
289+
"""
290+
# Compatibility for the 'name' argument
291+
if LooseVersion(pytest.__version__) >= LooseVersion('3.0.0'):
292+
# pytest version supports "name" keyword argument
293+
kwargs['name'] = name
294+
elif name is not None:
295+
# 'name' argument is not supported in this old version, use the __name__ trick.
296+
fixture_func.__name__ = name
297+
298+
# Collect all @pytest.mark.parametrize markers (including those created by usage of @cases_data)
299+
parametrizer_marks = get_pytest_parametrize_marks(fixture_func)
300+
301+
# the module will be used to add fixtures dynamically
302+
module = getmodule(fixture_func)
303+
304+
# for each dependency create an associated "param" fixture
305+
# Note: we could instead have created a huge parameter containing all parameters...
306+
# Pros = no additional fixture. Cons: less readable and ids would be difficult to create
307+
params_map = dict()
308+
for m in parametrizer_marks:
309+
# check what the mark specifies in terms of parameters
310+
if len(m.param_names) < 1:
311+
raise ValueError("Fixture function '%s' decorated with '@pytest_fixture_plus' has an empty parameter "
312+
"name in a @pytest.mark.parametrize mark")
206313

207-
# create a fixture function wrapper
208-
if not isgeneratorfunction(fixture_func):
209-
def wrapper(f, request, args, kwargs):
210-
kwargs[case_data_argname] = request.param
211-
return f(*args, **kwargs)
212314
else:
213-
def wrapper(f, request, args, kwargs):
214-
kwargs[case_data_argname] = request.param
215-
for res in f(*args, **kwargs):
216-
yield res
315+
# create a fixture function for this parameter
316+
def _param_fixture(request):
317+
"""a dummy fixture that simply returns the parameter"""
318+
return request.param
319+
320+
# generate a fixture name (find an available name if already used)
321+
gen_name = "gen_paramfixture__" + fixture_func.__name__ + "__" + 'X'.join(m.param_names)
322+
i = 0
323+
_param_fixture.__name__ = gen_name
324+
while _param_fixture.__name__ in dir(module):
325+
i += 1
326+
_param_fixture.__name__ = gen_name + '_' + str(i)
327+
328+
# create the fixture with param name, values and ids, and with same scope than requesting func.
329+
param_fixture = pytest.fixture(scope=scope, params=m.param_values, ids=m.param_ids)(_param_fixture)
330+
331+
# Add the fixture dynamically: we have to add it to the function holder module as explained in
332+
# https://github.com/pytest-dev/pytest/issues/2424
333+
if _param_fixture.__name__ not in dir(module):
334+
setattr(module, _param_fixture.__name__, param_fixture)
335+
else:
336+
raise ValueError("The {} fixture automatically generated by `@pytest_fixture_plus` already exists in "
337+
"module {}. This should not happen given the automatic name generation"
338+
"".format(_param_fixture.__name__, module))
339+
340+
# remember
341+
params_map[_param_fixture.__name__] = m.param_names
342+
343+
# wrap the fixture function so that each of its parameter becomes the associated fixture name
344+
new_parameter_names = tuple(params_map.keys())
345+
old_parameter_names = tuple(v for l in params_map.values() for v in l)
346+
347+
# common routine used below. Fills kwargs with the appropriate names and values from fixture_params
348+
def _get_arguments(fixture_params, args_and_kwargs):
349+
# unpack the underlying function's args/kwargs
350+
args = args_and_kwargs.pop('args')
351+
kwargs = args_and_kwargs.pop('kwargs')
352+
if len(args_and_kwargs) > 0:
353+
raise ValueError("Internal error - please file an issue in the github project page")
354+
355+
# fill the kwargs with additional arguments by using mapping
356+
i = 0
357+
for new_p_name in new_parameter_names:
358+
if len(params_map[new_p_name]) == 1:
359+
kwargs[params_map[new_p_name][0]] = fixture_params[i]
360+
i += 1
361+
else:
362+
# unpack several
363+
for old_p_name, old_p_value in zip(params_map[new_p_name], fixture_params[i]):
364+
kwargs[old_p_name] = old_p_value
365+
i += 1
217366

218-
fixture_func_wrapper = my_decorate(fixture_func, wrapper, additional_args=['request'],
219-
removed_args=[case_data_argname])
367+
return args, kwargs
220368

221-
# Finally create the pytest decorator and apply it
222-
parametrizer = pytest.fixture(params=_cases, ids=cases_ids, **kwargs)
223-
return parametrizer(fixture_func_wrapper)
369+
if not isgeneratorfunction(fixture_func):
370+
# normal function with return statement
371+
def wrapper(f, *fixture_params, **args_and_kwargs):
372+
args, kwargs = _get_arguments(fixture_params, args_and_kwargs)
373+
return fixture_func(*args, **kwargs)
374+
375+
wrapped_fixture_func = my_decorate(fixture_func, wrapper,
376+
additional_args=new_parameter_names, removed_args=old_parameter_names)
224377

225-
return fixture_decorator
378+
# transform the created wrapper into a fixture
379+
fixture_decorator = pytest.fixture(scope=scope, params=params, autouse=autouse, ids=ids, **kwargs)
380+
return fixture_decorator(wrapped_fixture_func)
381+
382+
else:
383+
# generator function (with a yield statement)
384+
def wrapper(f, *fixture_params, **args_and_kwargs):
385+
args, kwargs = _get_arguments(fixture_params, args_and_kwargs)
386+
for res in fixture_func(*args, **kwargs):
387+
yield res
388+
389+
wrapped_fixture_func = my_decorate(fixture_func, wrapper,
390+
additional_args=new_parameter_names, removed_args=old_parameter_names)
391+
392+
# transform the created wrapper into a fixture
393+
fixture_decorator = yield_fixture(scope=scope, params=params, autouse=autouse, ids=ids, **kwargs)
394+
return fixture_decorator(wrapped_fixture_func)
226395

227396

228397
def cases_data(cases=None, # type: Union[Callable[[Any], Any], Iterable[Callable[[Any], Any]]]

0 commit comments

Comments
 (0)