Coverage for src/pytest_cases/fixture_core1_unions.py: 90%
184 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-09-26 21:52 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2024-09-26 21:52 +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
7from inspect import isgeneratorfunction
8from warnings import warn
10from makefun import with_signature, add_signature_parameters, wraps
12import pytest
13import sys
15try: # python 3.3+
16 from inspect import signature, Parameter
17except ImportError:
18 from funcsigs import signature, Parameter # noqa
20try: # native coroutines, python 3.5+
21 from inspect import iscoroutinefunction
22except ImportError:
23 def iscoroutinefunction(obj):
24 return False
26try: # native async generators, python 3.6+
27 from inspect import isasyncgenfunction
28except ImportError:
29 def isasyncgenfunction(obj):
30 return False
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
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
45class _NotUsed:
46 def __repr__(self):
47 return "pytest_cases.NOT_USED"
50class _Used:
51 def __repr__(self):
52 return "pytest_cases.USED"
55NOT_USED = _NotUsed()
56"""Object representing a fixture value when the fixture is not used"""
59USED = _Used()
60"""Object representing a fixture value when the fixture is used"""
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()
74 @classmethod
75 def compact(cls,
76 param # type: UnionFixtureAlternative
77 ):
78 """ ids are /<fixture_name> """
79 return "/%s" % (param.get_alternative_id(),)
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())
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
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
111class UnionFixtureAlternative(object):
112 """Defines an "alternative", used to parametrize a fixture union"""
113 __slots__ = 'union_name', 'alternative_name', 'alternative_index'
115 def __init__(self,
116 union_name, # type: str
117 alternative_name, # type: str
118 alternative_index # type: int
119 ):
120 """
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
131 def get_union_id(self):
132 """Used by the id makers"""
133 return self.union_name
135 def get_alternative_idx(self):
136 """Used by the id makers"""
137 return self.alternative_index
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
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())
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)
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
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',
169 def __init__(self, params):
170 self.params = params
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
177def is_fixture_union_params(params):
178 """
179 Internal helper to quickly check if a bunch of parameters correspond to a union fixture.
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
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.
208 All fixtures that need to be union-compliant have to be decorated with `@ignore_unused`
210 :param request:
211 :return:
212 """
213 return getattr(request, 'param', None) is not NOT_USED
216def ignore_unused(fixture_func):
217 """
218 A decorator for fixture functions so that they are compliant with fixture unions.
219 It
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.
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.
228 :param fixture_func:
229 :return:
230 """
231 old_sig = signature(fixture_func)
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
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
271 return wrapped_fixture_func
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'])`
288 The style of test ids corresponding to the union alternatives can be changed with `idstyle`. Three values are
289 allowed:
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>`
296 See `UnionIdMakers` class for details.
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.
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()
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")
326 # unpack the pytest.param marks
327 custom_pids, p_marks, fixtures = extract_parameterset_info((name, ), fixtures)
329 # get all required fixture names
330 f_names = [get_fixture_name(f) for f in fixtures]
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)
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)
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)
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)
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)
361 return union_fix
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.
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")
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__))
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)
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)
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)
431 return new_union_fix
434_make_fixture_union = _fixture_union
435"""A readable alias for callers not using the returned symbol"""
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.
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')`.
452 ```python
453 import pytest
454 from pytest_cases import unpack_fixture, fixture
456 @fixture
457 @pytest.mark.parametrize("o", ['hello', 'world'])
458 def c(o):
459 return o, o[0]
461 a, b = unpack_fixture("a,b", c)
463 def test_function(a, b):
464 assert a[0] == b
465 ```
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.
470 ```python
471 import pytest
472 from pytest_cases import unpack_fixture, fixture
474 @fixture
475 @pytest.mark.parametrize("o", ['hello', 'world'])
476 def c(o):
477 return o, o[0]
479 class TestClass:
480 a, b = unpack_fixture("a,b", c, in_cls=True)
482 def test_function(self, a, b):
483 assert a[0] == b
484 ```
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)
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 """
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)
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'
537 # finally create the sub-fixtures
538 created_fixtures = []
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
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]
563 return _param_fixture
565 # create it
566 fix = _create_fixture(value_idx)
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)
573 # collect to return the whole list eventually
574 created_fixtures.append(fix)
576 return created_fixtures
579_make_unpack_fixture = _unpack_fixture
580"""A readable alias for callers not using the returned symbol"""