Hide keyboard shortcuts

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 

7 

8from makefun import add_signature_parameters, wraps, with_signature 

9 

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 

14 

15 

16try: # python 3.3+ 

17 from inspect import signature, Parameter 

18except ImportError: 

19 from funcsigs import signature, Parameter 

20 

21 

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' 

27 

28 

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 

37 

38 

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. 

43 

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. 

49 

50 See https://smarie.github.io/python-pytest-steps/ for examples. 

51 

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())) 

69 

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) 

73 

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) 

76 

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) 

85 

86 return decorator(test_fun) 

87 else: 

88 raise ValueError("Invalid step mode: %s" % step_mode) 

89 

90 return steps_decorator 

91 

92 

93test_steps.__test__ = False # to prevent pytest to think that this is a test ! 

94 

95 

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. 

99 

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. 

104 

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: 

108 

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 ``` 

118 

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`) 

121 

122 

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 ``` 

132 

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. 

136 

137 ------ 

138 

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`. 

141 

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 

152 

153 

154CROSS_STEPS_MARK = 'pytest_steps__is_cross_steps' 

155 

156 

157def cross_steps_fixture_decorate(fixture_fun, 

158 step_param_names=None): 

159 """ 

160 Implementation of the @cross_steps_fixture decorator, for manual decoration 

161 

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() 

169 

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 

178 

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)) 

197 

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) 

227 

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 

231 

232 

233def _get_step_param_names_or_default(step_param_names): 

234 """ 

235 

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