Coverage for src/pytest_cases/fixture_core1_unions.py: 90%

184 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 __future__ import division 

6 

7from inspect import isgeneratorfunction 

8from warnings import warn 

9 

10from makefun import with_signature, add_signature_parameters, wraps 

11 

12import pytest 

13import sys 

14 

15try: # python 3.3+ 

16 from inspect import signature, Parameter 

17except ImportError: 

18 from funcsigs import signature, Parameter # noqa 

19 

20try: # native coroutines, python 3.5+ 

21 from inspect import iscoroutinefunction 

22except ImportError: 

23 def iscoroutinefunction(obj): 

24 return False 

25 

26try: # native async generators, python 3.6+ 

27 from inspect import isasyncgenfunction 

28except ImportError: 

29 def isasyncgenfunction(obj): 

30 return False 

31 

32 

33try: # type hints, python 3+ 

34 from typing import Callable, Union, Optional, Any, List, Iterable, Sequence # noqa 

35 from types import ModuleType # noqa 

36except ImportError: 

37 pass 

38 

39from .common_mini_six import string_types 

40from .common_pytest import get_fixture_name, is_marked_parameter_value, get_marked_parameter_values, pytest_fixture, \ 

41 extract_parameterset_info, get_param_argnames_as_list, get_fixture_scope, resolve_ids 

42from .fixture__creation import get_caller_module, check_name_available, WARN 

43 

44 

45class _NotUsed: 

46 def __repr__(self): 

47 return "pytest_cases.NOT_USED" 

48 

49 

50class _Used: 

51 def __repr__(self): 

52 return "pytest_cases.USED" 

53 

54 

55NOT_USED = _NotUsed() 

56"""Object representing a fixture value when the fixture is not used""" 

57 

58 

59USED = _Used() 

60"""Object representing a fixture value when the fixture is used""" 

61 

62 

63class UnionIdMakers(object): 

64 """ 

65 The enum defining all possible id styles for union fixture parameters ("alternatives") 

66 """ 

67 @classmethod 

68 def nostyle(cls, 

69 param # type: UnionFixtureAlternative 

70 ): 

71 """ ids are <fixture_name> """ 

72 return param.get_alternative_id() 

73 

74 @classmethod 

75 def compact(cls, 

76 param # type: UnionFixtureAlternative 

77 ): 

78 """ ids are /<fixture_name> """ 

79 return "/%s" % (param.get_alternative_id(),) 

80 

81 @classmethod 

82 def explicit(cls, 

83 param # type: UnionFixtureAlternative 

84 ): 

85 """ ids are <union_name>/<fixture_name> """ 

86 return "%s/%s" % (param.get_union_id(), param.get_alternative_id()) 

87 

88 @classmethod 

89 def get(cls, style # type: Union[str, Callable] 

90 ): 

91 # type: (...) -> Callable[[UnionFixtureAlternative], str] 

92 """ 

93 Returns a function that one can use as the `ids` argument in parametrize, applying the given id style. 

94 See https://github.com/smarie/python-pytest-cases/issues/41 

95 

96 :param style: 

97 :return: 

98 """ 

99 if style is None or isinstance(style, string_types): 

100 # return one of the styles from the class 

101 style = style or 'nostyle' 

102 try: 

103 return getattr(cls, style) 

104 except AttributeError: 

105 raise ValueError("Unknown style: %r" % style) 

106 else: 

107 # assume a callable: return it directly 

108 return style 

109 

110 

111class UnionFixtureAlternative(object): 

112 """Defines an "alternative", used to parametrize a fixture union""" 

113 __slots__ = 'union_name', 'alternative_name', 'alternative_index' 

114 

115 def __init__(self, 

116 union_name, # type: str 

117 alternative_name, # type: str 

118 alternative_index # type: int 

119 ): 

120 """ 

121 

122 :param union_name: the name of the union fixture 

123 :param alternative_name: the name of the fixture that will be used by the union fixture when this alternative 

124 is active 

125 :param alternative_index: the index of the alternative, used for ids generation 

126 """ 

127 self.union_name = union_name 

128 self.alternative_name = alternative_name 

129 self.alternative_index = alternative_index 

130 

131 def get_union_id(self): 

132 """Used by the id makers""" 

133 return self.union_name 

134 

135 def get_alternative_idx(self): 

136 """Used by the id makers""" 

137 return self.alternative_index 

138 

139 def get_alternative_id(self): 

140 """Used by the id makers to get the minimal (no style) id. Defaults to the alternative name""" 

141 return self.alternative_name 

142 

143 def __str__(self): 

144 # This string representation can be used as an id if you pass `ids=str` to fixture_union for example 

145 return "%s/%s/%s" % (self.get_union_id(), self.get_alternative_idx(), self.get_alternative_id()) 

146 

147 def __repr__(self): 

148 return "%s(union_name=%s, alternative_index=%s, alternative_name=%s)" \ 

149 % (self.__class__.__name__, self.union_name, self.alternative_index, self.alternative_name) 

150 

151 @staticmethod 

152 def to_list_of_fixture_names(alternatives_lst # type: List[UnionFixtureAlternative] 

153 ): 

154 res = [] 

155 for f in alternatives_lst: 

156 if is_marked_parameter_value(f): 

157 f = get_marked_parameter_values(f, nbargs=1)[0] 

158 res.append(f.alternative_name) 

159 return res 

160 

161 

162class InvalidParamsList(Exception): 

163 """ 

164 Exception raised when users attempt to provide a non-iterable `argvalues` in pytest parametrize. 

165 See https://docs.pytest.org/en/latest/reference.html#pytest-mark-parametrize-ref 

166 """ 

167 __slots__ = 'params', 

168 

169 def __init__(self, params): 

170 self.params = params 

171 

172 def __str__(self): 

173 return "Invalid parameters list (`argvalues`) in pytest parametrize. `list(argvalues)` returned an error. " \ 

174 "Please make sure that `argvalues` is a list, tuple or iterable : %r" % self.params 

175 

176 

177def is_fixture_union_params(params): 

178 """ 

179 Internal helper to quickly check if a bunch of parameters correspond to a union fixture. 

180 

181 Note: unfortunately `pytest` transform all params to a list when a @pytest.fixture is created, 

182 so we can not pass a subclass of list to do the trick, we really have to work on the list elements. 

183 :param params: 

184 :return: 

185 """ 

186 try: 

187 if len(params) < 1: 

188 return False 

189 else: 

190 if getattr(params, '__module__', '').startswith('pytest_cases'): 190 ↛ 192line 190 didn't jump to line 192, because the condition on line 190 was never true

191 # a value_ref_tuple or another proxy object created somewhere in our code, not a list 

192 return False 

193 p0 = params[0] 

194 if is_marked_parameter_value(p0): 

195 p0 = get_marked_parameter_values(p0, nbargs=1)[0] 

196 return isinstance(p0, UnionFixtureAlternative) 

197 except: # noqa 

198 # be conservative 

199 # an iterable or the like - we do not use such things when we cope with fixture_refs and unions 

200 return False 

201 

202 

203def is_used_request(request): 

204 """ 

205 Internal helper to check if a given request for fixture is active or not. 

206 Inactive fixtures happen when a fixture is not used in the current branch of a UNION fixture. 

207 

208 All fixtures that need to be union-compliant have to be decorated with `@ignore_unused` 

209 

210 :param request: 

211 :return: 

212 """ 

213 return getattr(request, 'param', None) is not NOT_USED 

214 

215 

216def ignore_unused(fixture_func): 

217 """ 

218 A decorator for fixture functions so that they are compliant with fixture unions. 

219 It 

220 

221 - adds the `request` fixture dependency to their signature if needed 

222 - filters the calls based on presence of the `NOT_USED` token in the request params. 

223 

224 IMPORTANT: even if 'params' is not in kwargs, the fixture can be used in a fixture union and therefore a param 

225 *will* be received on some calls (and the fixture will be called several times - only once for real) - we have to 

226 handle the NOT_USED. 

227 

228 :param fixture_func: 

229 :return: 

230 """ 

231 old_sig = signature(fixture_func) 

232 

233 # add request if needed 

234 func_needs_request = 'request' in old_sig.parameters 

235 if not func_needs_request: 

236 # Add it last so that `self` argument in class functions remains the first 

237 new_sig = add_signature_parameters(old_sig, last=Parameter('request', kind=Parameter.POSITIONAL_OR_KEYWORD)) 

238 else: 

239 new_sig = old_sig 

240 

241 if isasyncgenfunction(fixture_func) and sys.version_info >= (3, 6): 

242 from .pep525 import _ignore_unused_asyncgen_pep525 

243 wrapped_fixture_func = _ignore_unused_asyncgen_pep525(fixture_func, new_sig, func_needs_request) 

244 elif iscoroutinefunction(fixture_func) and sys.version_info >= (3, 5): 

245 from .pep492 import _ignore_unused_coroutine_pep492 

246 wrapped_fixture_func = _ignore_unused_coroutine_pep492(fixture_func, new_sig, func_needs_request) 

247 elif isgeneratorfunction(fixture_func): 

248 if sys.version_info >= (3, 3): 248 ↛ 253line 248 didn't jump to line 253, because the condition on line 248 was never false

249 from .pep380 import _ignore_unused_generator_pep380 

250 wrapped_fixture_func = _ignore_unused_generator_pep380(fixture_func, new_sig, func_needs_request) 

251 else: 

252 # generator function (with a yield statement) 

253 @wraps(fixture_func, new_sig=new_sig) 

254 def wrapped_fixture_func(*args, **kwargs): 

255 request = kwargs['request'] if func_needs_request else kwargs.pop('request') 

256 if is_used_request(request): 

257 for res in fixture_func(*args, **kwargs): 

258 yield res 

259 else: 

260 yield NOT_USED 

261 else: 

262 # normal function with return statement 

263 @wraps(fixture_func, new_sig=new_sig) 

264 def wrapped_fixture_func(*args, **kwargs): 

265 request = kwargs['request'] if func_needs_request else kwargs.pop('request') 

266 if is_used_request(request): 

267 return fixture_func(*args, **kwargs) 

268 else: 

269 return NOT_USED 

270 

271 return wrapped_fixture_func 

272 

273 

274def fixture_union(name, # type: str 

275 fixtures, # type: Iterable[Union[str, Callable]] 

276 scope="function", # type: str 

277 idstyle='compact', # type: Optional[Union[str, Callable]] 

278 ids=None, # type: Union[Callable, Iterable[str]] 

279 unpack_into=None, # type: Iterable[str] 

280 autouse=False, # type: bool 

281 hook=None, # type: Callable[[Callable], Callable] 

282 **kwargs): 

283 """ 

284 Creates a fixture that will take all values of the provided fixtures in order. That fixture is automatically 

285 registered into the callers' module, but you may wish to assign it to a variable for convenience. In that case 

286 make sure that you use the same name, e.g. `a = fixture_union('a', ['b', 'c'])` 

287 

288 The style of test ids corresponding to the union alternatives can be changed with `idstyle`. Three values are 

289 allowed: 

290 

291 - `'explicit'` favors readability with names as `<union>/<alternative>`, 

292 - `'compact'` (default) adds a small mark so that at least one sees which parameters are union alternatives and 

293 which others are normal parameters: `/<alternative>` 

294 - `None` or `'nostyle'` provides minimalistic ids : `<alternative>` 

295 

296 See `UnionIdMakers` class for details. 

297 

298 You can also pass a callable `idstyle` that will receive instances of `UnionFixtureAlternative`. For example `str` 

299 leads to very explicit ids: `<union>/<idx>/<alternative>`. See `UnionFixtureAlternative` class for details. 

300 

301 :param name: the name of the fixture to create 

302 :param fixtures: an array-like containing fixture names and/or fixture symbols 

303 :param scope: the scope of the union. Since the union depends on the sub-fixtures, it should be smaller than the 

304 smallest scope of fixtures referenced. 

305 :param idstyle: The style of test ids corresponding to the union alternatives. One of `'explicit'`, `'compact'`, 

306 `'nostyle'`/`None`, or a callable (e.g. `str`) that will receive instances of `UnionFixtureAlternative`. 

307 :param ids: as in pytest. The default value returns the correct fixture 

308 :param unpack_into: an optional iterable of names, or string containing coma-separated names, for additional 

309 fixtures to create to represent parts of this fixture. See `unpack_fixture` for details. 

310 :param autouse: as in pytest 

311 :param hook: an optional hook to apply to each fixture function that is created during this call. The hook function 

312 will be called every time a fixture is about to be created. It will receive a single argument (the function 

313 implementing the fixture) and should return the function to use. For example you can use `saved_fixture` from 

314 `pytest-harvest` as a hook in order to save all such created fixtures in the fixture store. 

315 :param kwargs: other pytest fixture options. They might not be supported correctly. 

316 :return: the new fixture. Note: you do not need to capture that output in a symbol, since the fixture is 

317 automatically registered in your module. However if you decide to do so make sure that you use the same name. 

318 """ 

319 # grab the caller module, so that we can later create the fixture directly inside it 

320 caller_module = get_caller_module() 

321 

322 # test the `fixtures` argument to avoid common mistakes 

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

324 raise TypeError("fixture_union: the `fixtures` argument should be a tuple, set or list") 

325 

326 # unpack the pytest.param marks 

327 custom_pids, p_marks, fixtures = extract_parameterset_info((name, ), fixtures) 

328 

329 # get all required fixture names 

330 f_names = [get_fixture_name(f) for f in fixtures] 

331 

332 # create all alternatives and reapply the marks on them 

333 fix_alternatives = [] 

334 f_names_args = [] 

335 for _idx, (_name, _id, _mark) in enumerate(zip(f_names, custom_pids, p_marks)): 

336 # create the alternative object 

337 alternative = UnionFixtureAlternative(union_name=name, alternative_name=_name, alternative_index=_idx) 

338 

339 # remove duplicates in the fixture arguments: each is required only once by the union fixture to create 

340 if _name in f_names_args: 

341 warn("Creating a fixture union %r where two alternatives are the same fixture %r." % (name, _name)) 

342 else: 

343 f_names_args.append(_name) 

344 

345 # reapply the marks 

346 if _id is not None or (_mark or ()) != (): 

347 alternative = pytest.param(alternative, id=_id, marks=_mark or ()) 

348 fix_alternatives.append(alternative) 

349 

350 union_fix = _fixture_union(caller_module, name, 

351 fix_alternatives=fix_alternatives, unique_fix_alt_names=f_names_args, 

352 scope=scope, idstyle=idstyle, ids=ids, autouse=autouse, hook=hook, **kwargs) 

353 

354 # if unpacking is requested, do it here 

355 if unpack_into is not None: 

356 # Note: we can't expose the `in_cls` argument as we would not be able to output both the union and the 

357 # unpacked fixtures. However there is a simple workaround for this scenario of unpacking a union inside a class: 

358 # call unpack_fixture separately. 

359 _make_unpack_fixture(caller_module, argnames=unpack_into, fixture=name, hook=hook, in_cls=False) 

360 

361 return union_fix 

362 

363 

364def _fixture_union(fixtures_dest, 

365 name, # type: str 

366 fix_alternatives, # type: Sequence[UnionFixtureAlternative] 

367 unique_fix_alt_names, # type: List[str] 

368 scope="function", # type: str 

369 idstyle="compact", # type: Optional[Union[str, Callable]] 

370 ids=None, # type: Union[Callable, Iterable[str]] 

371 autouse=False, # type: bool 

372 hook=None, # type: Callable[[Callable], Callable] 

373 caller=fixture_union, # type: Callable 

374 **kwargs): 

375 """ 

376 Internal implementation for fixture_union. 

377 The "alternatives" have to be created beforehand, by the caller. This allows `fixture_union` and `parametrize` 

378 to use the same implementation while `parametrize` uses customized "alternatives" containing more information. 

379 

380 :param fixtures_dest: 

381 :param name: 

382 :param fix_alternatives: 

383 :param unique_fix_alt_names: 

384 :param idstyle: 

385 :param scope: 

386 :param ids: 

387 :param unpack_into: 

388 :param autouse: 

389 :param hook: an optional hook to apply to each fixture function that is created during this call. The hook function 

390 will be called every time a fixture is about to be created. It will receive a single argument (the function 

391 implementing the fixture) and should return the function to use. For example you can use `saved_fixture` from 

392 `pytest-harvest` as a hook in order to save all such created fixtures in the fixture store. 

393 :param caller: a function to reference for error messages 

394 :param kwargs: 

395 :return: 

396 """ 

397 if len(fix_alternatives) < 1: 397 ↛ 398line 397 didn't jump to line 398, because the condition on line 397 was never true

398 raise ValueError("Empty fixture unions are not permitted") 

399 

400 # then generate the body of our union fixture. It will require all of its dependent fixtures and receive as 

401 # a parameter the name of the fixture to use 

402 @with_signature("%s(%s, request)" % (name, ', '.join(unique_fix_alt_names))) 

403 def _new_fixture(request, **all_fixtures): 

404 # ignore the "not used" marks, like in @ignore_unused 

405 if not is_used_request(request): 

406 return NOT_USED 

407 else: 

408 _alternative = request.param 

409 if isinstance(_alternative, UnionFixtureAlternative): 409 ↛ 413line 409 didn't jump to line 413, because the condition on line 409 was never false

410 fixture_to_use = _alternative.alternative_name 

411 return all_fixtures[fixture_to_use] 

412 else: 

413 raise TypeError("Union Fixture %s received invalid parameter type: %s. Please report this issue." 

414 "" % (name, _alternative.__class__)) 

415 

416 if ids is None: 

417 ids = UnionIdMakers.get(idstyle) 

418 else: 

419 # resolve possibly infinite generators of ids here 

420 ids = resolve_ids(ids, fix_alternatives, full_resolve=False) 

421 

422 # finally create the fixture per se. 

423 _make_fix = pytest_fixture(scope=scope or "function", params=fix_alternatives, autouse=autouse, 

424 ids=ids, hook=hook, **kwargs) 

425 new_union_fix = _make_fix(_new_fixture) 

426 

427 # Dynamically add fixture to caller's module as explained in https://github.com/pytest-dev/pytest/issues/2424 

428 check_name_available(fixtures_dest, name, if_name_exists=WARN, caller=caller) 

429 setattr(fixtures_dest, name, new_union_fix) 

430 

431 return new_union_fix 

432 

433 

434_make_fixture_union = _fixture_union 

435"""A readable alias for callers not using the returned symbol""" 

436 

437 

438def unpack_fixture(argnames, # type: str 

439 fixture, # type: Union[str, Callable] 

440 in_cls=False, # type: bool 

441 hook=None # type: Callable[[Callable], Callable] 

442 ): 

443 """ 

444 Creates several fixtures with names `argnames` from the source `fixture`. Created fixtures will correspond to 

445 elements unpacked from `fixture` in order. For example if `fixture` is a tuple of length 2, `argnames="a,b"` will 

446 create two fixtures containing the first and second element respectively. 

447 

448 The created fixtures are automatically registered into the callers' module, but you may wish to assign them to 

449 variables for convenience. In that case make sure that you use the same names, 

450 e.g. `a, b = unpack_fixture('a,b', 'c')`. 

451 

452 ```python 

453 import pytest 

454 from pytest_cases import unpack_fixture, fixture 

455 

456 @fixture 

457 @pytest.mark.parametrize("o", ['hello', 'world']) 

458 def c(o): 

459 return o, o[0] 

460 

461 a, b = unpack_fixture("a,b", c) 

462 

463 def test_function(a, b): 

464 assert a[0] == b 

465 ``` 

466 

467 You can also use this function inside a class with `in_cls=True`. In that case you MUST assign the output of the 

468 function to variables, as the created fixtures won't be registered with the encompassing module. 

469 

470 ```python 

471 import pytest 

472 from pytest_cases import unpack_fixture, fixture 

473 

474 @fixture 

475 @pytest.mark.parametrize("o", ['hello', 'world']) 

476 def c(o): 

477 return o, o[0] 

478 

479 class TestClass: 

480 a, b = unpack_fixture("a,b", c, in_cls=True) 

481 

482 def test_function(self, a, b): 

483 assert a[0] == b 

484 ``` 

485 

486 :param argnames: same as `@pytest.mark.parametrize` `argnames`. 

487 :param fixture: a fixture name string or a fixture symbol. If a fixture symbol is provided, the created fixtures 

488 will have the same scope. If a name is provided, they will have scope='function'. Note that in practice the 

489 performance loss resulting from using `function` rather than a higher scope is negligible since the created 

490 fixtures' body is a one-liner. 

491 :param in_cls: a boolean (default False). You may wish to turn this to `True` to use this function inside a class. 

492 If you do so, you **MUST** assign the output to variables in the class. 

493 :param hook: an optional hook to apply to each fixture function that is created during this call. The hook function 

494 will be called every time a fixture is about to be created. It will receive a single argument (the function 

495 implementing the fixture) and should return the function to use. For example you can use `saved_fixture` from 

496 `pytest-harvest` as a hook in order to save all such created fixtures in the fixture store. 

497 :return: the created fixtures. 

498 """ 

499 if in_cls: 

500 # the user needs to capture the outputs of the function in symbols in the class 

501 caller_module = None 

502 else: 

503 # get the caller module to create the symbols in it. Assigning outputs is optional 

504 caller_module = get_caller_module() 

505 return _unpack_fixture(caller_module, argnames, fixture, hook=hook, in_cls=in_cls) 

506 

507 

508def _unpack_fixture(fixtures_dest, # type: ModuleType 

509 argnames, # type: Union[str, Iterable[str]] 

510 fixture, # type: Union[str, Callable] 

511 in_cls, # type: bool 

512 hook # type: Callable[[Callable], Callable] 

513 ): 

514 """ 

515 

516 :param fixtures_dest: if this is `None` the fixtures won't be registered anywhere (just returned) 

517 :param argnames: 

518 :param fixture: 

519 :param in_cls: a boolean indicating if the `self` argument should be prepended. 

520 :param hook: an optional hook to apply to each fixture function that is created during this call. The hook function 

521 will be called every time a fixture is about to be created. It will receive a single argument (the function 

522 implementing the fixture) and should return the function to use. For example you can use `saved_fixture` from 

523 `pytest-harvest` as a hook in order to save all such created fixtures in the fixture store. 

524 :return: 

525 """ 

526 # unpack fixture names to create if needed 

527 argnames_lst = get_param_argnames_as_list(argnames) 

528 

529 # possibly get the source fixture name if the fixture symbol was provided 

530 source_f_name = get_fixture_name(fixture) 

531 if not isinstance(fixture, string_types): 

532 scope = get_fixture_scope(fixture) 

533 else: 

534 # we dont have a clue about the real scope, so lets use function scope 

535 scope = 'function' 

536 

537 # finally create the sub-fixtures 

538 created_fixtures = [] 

539 

540 # we'll need to create their signature 

541 if in_cls: 

542 _sig = "(self, %s, request)" % source_f_name 

543 else: 

544 _sig = "(%s, request)" % source_f_name 

545 

546 for value_idx, argname in enumerate(argnames_lst): 

547 # create the fixture 

548 # To fix late binding issue with `value_idx` we add an extra layer of scope: a factory function 

549 # See https://stackoverflow.com/questions/3431676/creating-functions-in-a-loop 

550 def _create_fixture(_value_idx): 

551 # no need to autouse=True: this fixture does not bring any added value in terms of setup. 

552 @pytest_fixture(name=argname, scope=scope, autouse=False, hook=hook) 

553 @with_signature(argname + _sig) 

554 def _param_fixture(request, **kwargs): 

555 # ignore the "not used" marks, like in @ignore_unused 

556 if not is_used_request(request): 

557 return NOT_USED 

558 # get the required fixture's value (the tuple to unpack) 

559 source_fixture_value = kwargs.pop(source_f_name) 

560 # unpack: get the item at the right position. 

561 return source_fixture_value[_value_idx] 

562 

563 return _param_fixture 

564 

565 # create it 

566 fix = _create_fixture(value_idx) 

567 

568 if fixtures_dest is not None: 

569 # add to module 

570 check_name_available(fixtures_dest, argname, if_name_exists=WARN, caller=unpack_fixture) 

571 setattr(fixtures_dest, argname, fix) 

572 

573 # collect to return the whole list eventually 

574 created_fixtures.append(fix) 

575 

576 return created_fixtures 

577 

578 

579_make_unpack_fixture = _unpack_fixture 

580"""A readable alias for callers not using the returned symbol"""