Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Authors: Sylvain MARIE <sylvain.marie@se.com>
2# + All contributors to <https://github.com/smarie/python-pytest-steps>
3#
4# License: 3-clause BSD, <https://github.com/smarie/python-pytest-steps/blob/master/LICENSE>
5from inspect import isgeneratorfunction
6from sys import version_info
8from makefun import add_signature_parameters, wraps, with_signature
10from .common_mini_six import string_types
11from .steps_common import get_pytest_node_hash_id, get_scope
12from .steps_generator import get_generator_decorator, GENERATOR_MODE_STEP_ARGNAME
13from .steps_parametrizer import get_parametrize_decorator
16try: # python 3.3+
17 from inspect import signature, Parameter
18except ImportError:
19 from funcsigs import signature, Parameter
22TEST_STEP_MODE_AUTO = 'auto'
23TEST_STEP_MODE_GENERATOR = 'generator'
24TEST_STEP_MODE_PARAMETRIZER = 'parametrizer'
25TEST_STEP_ARGNAME_DEFAULT = 'test_step'
26STEPS_DATA_HOLDER_NAME_DEFAULT = 'steps_data'
29# Python 3+: load the 'more explicit api' for `test_steps`
30if version_info >= (3, 0): 30 ↛ 36line 30 didn't jump to line 36, because the condition on line 30 was never false
31 new_sig = """(*steps,
32 mode: str = TEST_STEP_MODE_AUTO,
33 test_step_argname: str = TEST_STEP_ARGNAME_DEFAULT,
34 steps_data_holder_name: str = STEPS_DATA_HOLDER_NAME_DEFAULT)"""
35else:
36 new_sig = None
39@with_signature(new_sig)
40def test_steps(*steps, **kwargs):
41 """
42 Decorates a test function so as to automatically parametrize it with all steps listed as arguments.
44 There are two main ways to use this decorator:
45 1. decorate a test function generator and provide as many step names as there are 'yield' statements in the
46 generator
47 2. decorate a test function with a 'test_step' parameter, and use this parameter in the test function body to
48 decide what to execute.
50 See https://smarie.github.io/python-pytest-steps/ for examples.
52 :param steps: a list of test steps. They can be anything, but typically they will be string (when mode is
53 'generator') or non-test (not prefixed with 'test') functions (when mode is 'parametrizer').
54 :param mode: one of {'auto', 'generator', 'parametrizer'}. In 'auto' mode (default), the decorator will detect if
55 your function is a generator or not. If it is a generator it will use the 'generator' mode, otherwise it will
56 use the 'parametrizer' (explicit) mode.
57 :param test_step_argname: the optional name of the function argument that will receive the test step object.
58 Default is 'test_step'.
59 :param steps_data_holder_name: the optional name of the function argument that will receive the shared
60 `StepsDataHolder` object if present. Default is 'results'.
61 :return:
62 """
63 # python 2 compatibility: no keyword arguments can follow a *args.
64 step_mode = kwargs.pop('mode', TEST_STEP_MODE_AUTO)
65 test_step_argname = kwargs.pop('test_step_argname', TEST_STEP_ARGNAME_DEFAULT)
66 steps_data_holder_name = kwargs.pop('steps_data_holder_name', STEPS_DATA_HOLDER_NAME_DEFAULT)
67 if len(kwargs) > 0: 67 ↛ 68line 67 didn't jump to line 68, because the condition on line 67 was never true
68 raise ValueError("Invalid argument(s): " + str(kwargs.keys()))
70 # create decorator according to mode
71 if step_mode == TEST_STEP_MODE_GENERATOR: 71 ↛ 72line 71 didn't jump to line 72, because the condition on line 71 was never true
72 steps_decorator = get_generator_decorator(steps)
74 elif step_mode == TEST_STEP_MODE_PARAMETRIZER: 74 ↛ 75line 74 didn't jump to line 75, because the condition on line 74 was never true
75 steps_decorator = get_parametrize_decorator(steps, steps_data_holder_name, test_step_argname)
77 elif step_mode == TEST_STEP_MODE_AUTO: 77 ↛ 88line 77 didn't jump to line 88, because the condition on line 77 was never false
78 # in this mode we decide later, when seeing the function
79 def steps_decorator(test_fun):
80 # check if the function is a generator function or not
81 if isgeneratorfunction(test_fun):
82 decorator = get_generator_decorator(steps)
83 else:
84 decorator = get_parametrize_decorator(steps, steps_data_holder_name, test_step_argname)
86 return decorator(test_fun)
87 else:
88 raise ValueError("Invalid step mode: %s" % step_mode)
90 return steps_decorator
93test_steps.__test__ = False # to prevent pytest to think that this is a test !
96def cross_steps_fixture(step_param_names):
97 """
98 A decorator for a function-scoped fixture so that it is not called for each step, but only once for all steps.
100 Decorating your fixture with `@cross_steps_fixture` tells `@test_steps` to detect when the fixture function is
101 called for the first step, to cache that first step instance, and to reuse it instead of calling your fixture
102 function for subsequent steps. This results in all steps (with the same other parameters) using the same fixture
103 instance.
105 Everything that is placed **below** this decorator will be called only once for all steps. For example if you use
106 it in combination with `@saved_fixture` from `pytest-harvest` you will get the two possible behaviours below
107 depending on the order of the decorators:
109 Order A (recommended):
110 --------------------
111 ```python
112 @pytest.fixture
113 @saved_fixture
114 @cross_steps_fixture
115 def my_cool_fixture():
116 return random()
117 ```
119 `@saved_fixture` will be executed for *all* steps, and the saved object will be the same for all steps (since it
120 will be cached by `@cross_steps_fixture`)
123 Order B:
124 -------
125 ```python
126 @pytest.fixture
127 @cross_steps_fixture
128 @saved_fixture
129 def my_cool_fixture():
130 return random()
131 ```
133 `@saved_fixture` will only be called for the first step. Indeed for subsequent steps, `@cross_steps_fixture`
134 will directly return and prevent the underlying functions to be called. This is not a very interesting behaviour in
135 this case, but with other decorators it might be interesting.
137 ------
139 If you use custom test step parameter names and not the default, you will have to provide an exhaustive list in
140 `step_param_names`.
142 :param step_param_names: a singleton or iterable containing the names of the test step parameters used in the
143 tests. By default the list is `[GENERATOR_MODE_STEP_ARGNAME, TEST_STEP_ARGNAME_DEFAULT]` to cover both
144 generator-mode and legacy manual mode.
145 :return:
146 """
147 if callable(step_param_names): 147 ↛ 151line 147 didn't jump to line 151, because the condition on line 147 was never false
148 # decorator used without argument, this is the function not the param
149 return cross_steps_fixture_decorate(step_param_names)
150 else:
151 return cross_steps_fixture_decorate
154CROSS_STEPS_MARK = 'pytest_steps__is_cross_steps'
157def cross_steps_fixture_decorate(fixture_fun,
158 step_param_names=None):
159 """
160 Implementation of the @cross_steps_fixture decorator, for manual decoration
162 :param fixture_fun:
163 :param step_param_names: a singleton or iterable containing the names of the test step parameters used in the
164 tests. By default the list is `[GENERATOR_MODE_STEP_ARGNAME, TEST_STEP_ARGNAME_DEFAULT]` to cover both
165 generator-mode and legacy manual mode.
166 :return:
167 """
168 ref_dct = dict()
170 # Create the function wrapper.
171 # We will expose a new signature with additional 'request' arguments if needed, and the test step
172 orig_sig = signature(fixture_fun)
173 func_needs_request = 'request' in orig_sig.parameters
174 if not func_needs_request: 174 ↛ 177line 174 didn't jump to line 177, because the condition on line 174 was never false
175 new_sig = add_signature_parameters(orig_sig, first=Parameter('request', kind=Parameter.POSITIONAL_OR_KEYWORD))
176 else:
177 new_sig = orig_sig
179 def _init_and_check(request):
180 """
181 Checks that the current request is not session but a specific node.
182 :param request:
183 :return:
184 """
185 scope = get_scope(request)
186 if scope == 'function': 186 ↛ 194line 186 didn't jump to line 194, because the condition on line 186 was never false
187 # function-scope: ok
188 id_without_steps = get_pytest_node_hash_id(
189 request.node, params_to_ignore=_get_step_param_names_or_default(step_param_names)
190 )
191 return id_without_steps
192 else:
193 # session- or module-scope
194 raise Exception("The `@cross_steps_fixture` decorator is only useful for function-scope fixtures. `%s`"
195 " seems to have scope='%s'. Consider removing `@cross_steps_fixture` or changing "
196 "the scope to 'function'." % (fixture_fun, scope))
198 if not isgeneratorfunction(fixture_fun): 198 ↛ 212line 198 didn't jump to line 212, because the condition on line 198 was never false
199 @wraps(fixture_fun, new_sig=new_sig)
200 def _steps_aware_decorated_function(*args, **kwargs):
201 request = kwargs['request'] if func_needs_request else kwargs.pop('request')
202 id_without_steps = _init_and_check(request)
203 try:
204 # already available: this is a subsequent step.
205 return ref_dct[id_without_steps]
206 except KeyError:
207 # not yet cached, this is probably the first step
208 res = fixture_fun(*args, **kwargs)
209 ref_dct[id_without_steps] = res
210 return res
211 else:
212 @wraps(fixture_fun, new_sig=new_sig)
213 def _steps_aware_decorated_function(*args, **kwargs):
214 request = kwargs['request'] if func_needs_request else kwargs.pop('request')
215 id_without_steps = _init_and_check(request)
216 try:
217 # already available: this is a subsequent step.
218 yield ref_dct[id_without_steps]
219 except KeyError:
220 # not yet cached, this is probably the first step
221 gen = fixture_fun(*args, **kwargs)
222 res = next(gen)
223 ref_dct[id_without_steps] = res
224 yield res
225 # TODO this teardown hook should actually be executed after all steps...
226 next(gen)
228 # Tag the function as being "cross-step" for future usage
229 setattr(_steps_aware_decorated_function, CROSS_STEPS_MARK, True)
230 return _steps_aware_decorated_function
233def _get_step_param_names_or_default(step_param_names):
234 """
236 :param step_param_names:
237 :return: a list of step parameter names
238 """
239 if step_param_names is None: 239 ↛ 242line 239 didn't jump to line 242, because the condition on line 239 was never false
240 # default: cover both generator and legacy mode default names
241 step_param_names = [GENERATOR_MODE_STEP_ARGNAME, TEST_STEP_ARGNAME_DEFAULT]
242 elif isinstance(step_param_names, string_types):
243 # singleton
244 step_param_names = [step_param_names]
245 return step_param_names