Coverage for src/pytest_cases/case_funcs.py: 89%

115 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-04-04 21:17 +0000

1# Authors: Sylvain MARIE <sylvain.marie@se.com> 

2# + All contributors to <https://github.com/smarie/python-pytest-cases> 

3# 

4# License: 3-clause BSD, <https://github.com/smarie/python-pytest-cases/blob/master/LICENSE> 

5from copy import copy 

6from decopatch import function_decorator, DECORATED 

7 

8try: # python 3.5+ 

9 from typing import Callable, Union, Optional, Any, Tuple, Iterable, List, Set 

10except ImportError: 

11 pass 

12 

13from .common_mini_six import string_types 

14from .common_pytest import safe_isclass 

15from .common_pytest_marks import get_pytest_marks_on_function, markdecorators_as_tuple, markdecorators_to_markinfos 

16 

17try: 

18 from _pytest.mark.structures import MarkDecorator, Mark 

19except ImportError: 

20 pass 

21 

22 

23# ------------------ API -------------- 

24CASE_PREFIX_CLS = 'Case' 

25"""Prefix used by default to identify case classes""" 

26 

27CASE_PREFIX_FUN = 'case_' 

28"""Prefix used by default to identify case functions within a module""" 

29 

30 

31CASE_FIELD = '_pytestcase' 

32 

33 

34class _CaseInfo(object): 

35 """ 

36 Contains all information available about a case. 

37 It is attached to a case function as an attribute. 

38 

39 Currently we do not wish to export an object-oriented API for this but rather a set of functions. 

40 This is why this class remains private. Public functions to access the various elements in this class 

41 are provided below (`get_case_id`, `get_case_tags` and `get_case_marks`). This is a safeguard to allow us 

42 to change this class design later while easily guaranteeing retrocompatibility. 

43 """ 

44 __slots__ = ('id', 'marks', 'tags') 

45 

46 def __init__(self, 

47 id=None, # type: str 

48 marks=(), # type: Tuple[MarkDecorator, ...] 

49 tags=() # type: Tuple[Any] 

50 ): 

51 self.id = id 

52 self.marks = marks # type: Tuple[MarkDecorator, ...] 

53 self.tags = () 

54 self.add_tags(tags) 

55 

56 def __repr__(self): 

57 return "_CaseInfo(id=%r,marks=%r,tags=%r)" % (self.id, self.marks, self.tags) 

58 

59 @classmethod 

60 def get_from(cls, 

61 case_func, # type: Callable 

62 create_if_missing=False # type: bool 

63 ): 

64 """ Return the _CaseInfo associated with case_fun or None 

65 

66 :param case_func: 

67 :param create_if_missing: if no case information is present on the function, by default None is returned. If 

68 this flag is set to True, a new _CaseInfo will be created and attached on the function, and returned. 

69 """ 

70 ci = getattr(case_func, CASE_FIELD, None) 

71 if ci is None and create_if_missing: 71 ↛ 72line 71 didn't jump to line 72, because the condition on line 71 was never true

72 ci = cls() 

73 ci.attach_to(case_func) 

74 return ci 

75 

76 def attach_to(self, 

77 case_func # type: Callable 

78 ): 

79 """attach this case_info to the given case function""" 

80 setattr(case_func, CASE_FIELD, self) 

81 

82 def add_tags(self, 

83 tags # type: Union[Any, Union[List, Set, Tuple]] 

84 ): 

85 """add the given tag or tags""" 

86 if tags: 

87 if isinstance(tags, string_types) or not isinstance(tags, (set, list, tuple)): 

88 # a single tag, create a tuple around it 

89 tags = (tags,) 

90 

91 self.tags += tuple(tags) 

92 

93 def matches_tag_query(self, 

94 has_tag=None, # type: Union[str, Iterable[str]] 

95 ): 

96 """ 

97 Returns True if the case function with this case_info is selected by the query 

98 

99 :param has_tag: 

100 :return: 

101 """ 

102 return _tags_match_query(self.tags, has_tag) 

103 

104 @classmethod 

105 def copy_info(cls, 

106 from_case_func, 

107 to_case_func): 

108 case_info = cls.get_from(from_case_func) 

109 if case_info is not None: 

110 # there is something to copy: do it 

111 cp = copy(case_info) 

112 cp.attach_to(to_case_func) 

113 

114 

115def _tags_match_query(tags, # type: Iterable[str] 

116 has_tag # type: Optional[Union[str, Iterable[str]]] 

117 ): 

118 """Internal routine to determine is all tags in `has_tag` are persent in `tags` 

119 Note that `has_tag` can be a single tag, or none 

120 """ 

121 if has_tag is None: 121 ↛ 122line 121 didn't jump to line 122, because the condition on line 121 was never true

122 return True 

123 

124 if not isinstance(has_tag, (tuple, list, set)): 

125 has_tag = (has_tag,) 

126 

127 return all(t in tags for t in has_tag) 

128 

129 

130def copy_case_info(from_fun, # type: Callable 

131 to_fun # type: Callable 

132 ): 

133 """Copy all information from case function `from_fun` to `to_fun`.""" 

134 _CaseInfo.copy_info(from_fun, to_fun) 

135 

136 

137def set_case_id(id, # type: str 

138 case_func # type: Callable 

139 ): 

140 """Set an explicit id on case function `case_func`.""" 

141 ci = _CaseInfo.get_from(case_func, create_if_missing=True) 

142 ci.id = id 

143 

144 

145def get_case_id(case_func, # type: Callable 

146 prefix_for_default_ids=CASE_PREFIX_FUN # type: str 

147 ): 

148 """Return the case id associated with this case function. 

149 

150 If a custom id is not present, a case id is automatically created from the function name based on removing the 

151 provided prefix if present at the beginning of the function name. If the resulting case id is empty, 

152 "<empty_case_id>" will be returned. 

153 

154 :param case_func: the case function to get a case id for 

155 :param prefix_for_default_ids: this prefix that will be removed if present on the function name to form the default 

156 case id. 

157 :return: 

158 """ 

159 _ci = _CaseInfo.get_from(case_func) 

160 _id = _ci.id if _ci is not None else None 

161 

162 if _id is None: 

163 # default case id from function name based on prefix 

164 if case_func.__name__.startswith(prefix_for_default_ids): 

165 _id = case_func.__name__[len(prefix_for_default_ids):] 

166 else: 

167 _id = case_func.__name__ 

168 

169 # default case id for empty id 

170 if len(_id) == 0: 

171 _id = "<empty_case_id>" 

172 

173 return _id 

174 

175 

176# def add_case_marks: no need, equivalent of @case(marks) or @mark 

177 

178 

179def get_case_marks(case_func, # type: Callable 

180 concatenate_with_fun_marks=False, # type: bool 

181 as_decorators=False # type: bool 

182 ): 

183 # type: (...) -> Union[Tuple[Mark, ...], Tuple[MarkDecorator, ...]] 

184 """Return the marks that are on the case function. 

185 

186 There are currently two ways to place a mark on a case function: either with `@pytest.mark.<name>` or in 

187 `@case(marks=...)`. This function returns a list of marks containing either both (if `concatenate_with_fun_marks` is 

188 `True`) or only the ones set with `@case` (`concatenate_with_fun_marks` is `False`, default). 

189 

190 :param case_func: the case function 

191 :param concatenate_with_fun_marks: if `False` (default) only the marks declared in `@case` will be returned. 

192 Otherwise a concatenation of marks in `@case` and on the function (for example directly with 

193 `@pytest.mark.<name>`) will be returned. 

194 :param as_decorators: when `True`, the marks (`MarkInfo`) will be transformed into `MarkDecorators` before being 

195 returned. Otherwise (default) the marks are returned as is. 

196 :return: 

197 """ 

198 _ci = _CaseInfo.get_from(case_func) 

199 if _ci is None: 

200 _ci_marks = None 

201 else: 

202 # convert the MarkDecorators to Marks if needed 

203 _ci_marks = _ci.marks if as_decorators else markdecorators_to_markinfos(_ci.marks) 

204 

205 if not concatenate_with_fun_marks: 

206 return _ci_marks 

207 else: 

208 # concatenate the marks on the `_CaseInfo` with the ones on `case_func` 

209 fun_marks = tuple(get_pytest_marks_on_function(case_func, as_decorators=as_decorators)) 

210 return (_ci_marks + fun_marks) if _ci_marks else fun_marks 

211 

212 

213# def add_case_tags(case_func, 

214# tags 

215# ): 

216# """Adds tags on the case function, for filtering. This is equivalent to `@case(tags=...)(case_func)`""" 

217# ci = _CaseInfo.get_from(case_func, create_if_missing=True) 

218# ci.add_tags(tags) 

219 

220 

221def get_case_tags(case_func # type: Callable 

222 ): 

223 """Return the tags on this case function or an empty tuple""" 

224 ci = _CaseInfo.get_from(case_func) 

225 return ci.tags if ci is not None else () 

226 

227 

228def matches_tag_query(case_fun, # type: Callable 

229 has_tag=None, # type: Union[str, Iterable[str]] 

230 filter=None, # type: Union[Callable[[Callable], bool], Iterable[Callable[[Callable], bool]]] # noqa 

231 ): 

232 """ 

233 This function is the one used by `@parametrize_with_cases` to filter the case functions collected. It can be used 

234 manually for tests/debug. 

235 

236 Returns True if the case function is selected by the query: 

237 

238 - if `has_tag` contains one or several tags, they should ALL be present in the tags 

239 set on `case_fun` (`get_case_tags`) 

240 

241 - if `filter` contains one or several filter callables, they are all called in sequence and the 

242 `case_fun` is only selected if ALL of them return a `True` truth value 

243 

244 :param case_fun: the case function 

245 :param has_tag: one or several tags that should ALL be present in the tags set on `case_fun` for it to be selected. 

246 :param filter: one or several filter callables that will be called in sequence. If all of them return a `True` 

247 truth value, `case_fun` is selected. 

248 :return: True if the case_fun is selected by the query. 

249 """ 

250 selected = True 

251 

252 # query on tags 

253 if has_tag is not None: 

254 selected = selected and _tags_match_query(get_case_tags(case_fun), has_tag) 

255 

256 # filter function 

257 if filter is not None: 257 ↛ 275line 257 didn't jump to line 275, because the condition on line 257 was never false

258 if not isinstance(filter, (tuple, set, list)): 258 ↛ 259line 258 didn't jump to line 259, because the condition on line 258 was never true

259 filter = (filter,) 

260 

261 for _filter in filter: 

262 # break if already unselected 

263 if not selected: 263 ↛ 264line 263 didn't jump to line 264, because the condition on line 263 was never true

264 return selected 

265 

266 # try next filter 

267 try: 

268 res = _filter(case_fun) 

269 # keep this in the try catch in case there is an issue with the truth value of result 

270 selected = selected and res 

271 except: # noqa 

272 # any error leads to a no-match 

273 selected = False 

274 

275 return selected 

276 

277 

278try: 

279 SeveralMarkDecorators = Union[Tuple[MarkDecorator, ...], List[MarkDecorator], Set[MarkDecorator]] 

280except: # noqa 

281 pass 

282 

283 

284@function_decorator 

285def case(id=None, # type: str # noqa 

286 tags=None, # type: Union[Any, Iterable[Any]] 

287 marks=(), # type: Union[MarkDecorator, SeveralMarkDecorators] 

288 case_func=DECORATED # noqa 

289 ): 

290 """ 

291 Optional decorator for case functions so as to customize some information. 

292 

293 ```python 

294 @case(id='hey') 

295 def case_hi(): 

296 return 1 

297 ``` 

298 

299 :param id: the custom pytest id that should be used when this case is active. Replaces the deprecated `@case_name` 

300 decorator from v1. If no id is provided, the id is generated from case functions by removing their prefix, 

301 see `@parametrize_with_cases(prefix='case_')`. 

302 :param tags: custom tags to be used for filtering in `@parametrize_with_cases(has_tags)`. Replaces the deprecated 

303 `@case_tags` and `@target` decorators. 

304 :param marks: optional pytest marks to add on the case. Note that decorating the function directly with the mark 

305 also works, and if marks are provided in both places they are merged. 

306 :return: 

307 """ 

308 marks = markdecorators_as_tuple(marks) 

309 case_info = _CaseInfo(id, marks, tags) 

310 case_info.attach_to(case_func) 

311 return case_func 

312 

313 

314def is_case_class(cls, # type: Any 

315 case_marker_in_name=CASE_PREFIX_CLS, # type: str 

316 check_name=True # type: bool 

317 ): 

318 """ 

319 This function is the one used by `@parametrize_with_cases` to collect cases within classes. It can be used manually 

320 for tests/debug. 

321 

322 Returns True if the given object is a class and, if `check_name=True` (default), if its name contains 

323 `case_marker_in_name`. 

324 

325 :param cls: the object to check 

326 :param case_marker_in_name: the string that should be present in a class name so that it is selected. Default is 

327 'Case'. 

328 :param check_name: a boolean (default True) to enforce that the name contains the word `case_marker_in_name`. 

329 If False, any class will lead to a `True` result whatever its name. 

330 :return: True if this is a case class 

331 """ 

332 return safe_isclass(cls) and (not check_name or case_marker_in_name in cls.__name__) 

333 

334 

335GEN_BY_US = '_pytestcases_gen' 

336 

337 

338def is_case_function(f, # type: Any 

339 prefix=CASE_PREFIX_FUN, # type: str 

340 check_prefix=True # type: bool 

341 ): 

342 """ 

343 This function is the one used by `@parametrize_with_cases` to collect cases. It can be used manually for 

344 tests/debug. 

345 

346 Returns True if the provided object is a function or callable and, if `check_prefix=True` (default), if it starts 

347 with `prefix`. 

348 

349 :param f: the object to check 

350 :param prefix: the string that should be present at the beginning of a function name so that it is selected. 

351 Default is 'case_'. 

352 :param check_prefix: if this boolean is True (default), the prefix will be checked. If False, any function will 

353 lead to a `True` result whatever its name. 

354 :return: 

355 """ 

356 if not callable(f): 

357 return False 

358 elif safe_isclass(f): 

359 return False 

360 elif hasattr(f, GEN_BY_US): 

361 # a function generated by us. ignore this 

362 return False 

363 else: 

364 try: 

365 return f.__name__.startswith(prefix) if check_prefix else True 

366 except: 

367 # GH#287: safe fallback 

368 return False